mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 00:14:33 +00:00
Delete plans/260406-1304-goclaw-v3-core-design directory
This commit is contained in:
1 parent
ea3353e397
commit
6a34b7ec65
7 files changed
-4502
No files matched your search
@@ -1,638 +0,0 @@
|
||||
# Phase 1: Foundation Interfaces
|
||||
|
||||
## Context Links
|
||||
|
||||
- Brainstorm: `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md` (decisions 5, 8, 14, 10)
|
||||
- Architecture: `plans/reports/Explore-260406-thorough-v3-architecture.md` (sections 1, 2, 6, 7)
|
||||
- Loop deep dive: `plans/reports/Explore-260406-GoClaw-Agent-Loop-v3-Deep-Dive.md` (section 2, 3)
|
||||
- Workspace forcing: `plans/reports/Explore-260406-workspace-forcing.md`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** P1 (all other phases depend on these)
|
||||
- **Status:** Complete
|
||||
- **Effort:** 8h design
|
||||
|
||||
4 foundational components with zero cross-dependencies. Can be designed and implemented in parallel. Every subsequent phase (pipeline, memory, integration) depends on at least one of these.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Token counting currently `chars/4` estimate -- drives ALL compaction/pruning decisions. Replacing with tiktoken-go is highest-ROI change
|
||||
- Workspace resolution has 5 identified confusion sources (see workspace forcing report). Single immutable WorkspaceContext eliminates dual-key confusion
|
||||
- EventBus currently `map[string]any` payloads, string topic constants. V3 needs typed events for consolidation pipeline
|
||||
- Provider interface (`providers.Provider`) is functional but loop handles provider quirks directly. Adapter pattern isolates quirks
|
||||
|
||||
---
|
||||
|
||||
## A. TokenCounter Interface
|
||||
|
||||
### Interface Contract
|
||||
|
||||
```go
|
||||
// Package: internal/tokencount
|
||||
|
||||
// TokenCounter provides accurate per-model token counting.
|
||||
// Replaces chars/4 estimation used in compaction + pruning.
|
||||
type TokenCounter interface {
|
||||
// Count returns token count for raw text using model's tokenizer.
|
||||
Count(model string, text string) int
|
||||
|
||||
// CountMessages returns token count for a message list,
|
||||
// including per-message overhead (role tokens, separators).
|
||||
CountMessages(model string, msgs []providers.Message) int
|
||||
|
||||
// ModelContextWindow returns max context tokens for a model.
|
||||
// Falls back to provider default if model unknown.
|
||||
ModelContextWindow(model string) int
|
||||
}
|
||||
|
||||
// TokenizerID identifies which tokenizer a model uses.
|
||||
type TokenizerID string
|
||||
|
||||
const (
|
||||
TokenizerCL100K TokenizerID = "cl100k_base" // Claude, GPT-3.5/4
|
||||
TokenizerO200K TokenizerID = "o200k_base" // GPT-4o, GPT-5
|
||||
TokenizerFallback TokenizerID = "fallback" // chars/4
|
||||
)
|
||||
|
||||
// ModelRegistry maps model name prefixes to tokenizer + context window.
|
||||
type ModelInfo struct {
|
||||
TokenizerID TokenizerID
|
||||
ContextWindow int // default context window for this model
|
||||
}
|
||||
|
||||
// DefaultRegistry provides built-in model mappings.
|
||||
// Extend at runtime via RegisterModel().
|
||||
var DefaultRegistry = map[string]ModelInfo{
|
||||
"claude-": {TokenizerCL100K, 200_000},
|
||||
"gpt-4o": {TokenizerO200K, 128_000},
|
||||
"gpt-4": {TokenizerCL100K, 128_000},
|
||||
"gpt-5": {TokenizerO200K, 1_000_000},
|
||||
"qwen-": {TokenizerCL100K, 128_000}, // approx
|
||||
"deepseek-": {TokenizerCL100K, 128_000}, // approx
|
||||
}
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
```go
|
||||
// cachedCounter wraps a tokenizer with per-message hash cache.
|
||||
// Key: SHA-256(model + role + content + tool_call_ids)
|
||||
// Value: token count (int)
|
||||
// Eviction: LRU, 10K entries (typical session < 500 messages)
|
||||
type cachedCounter struct {
|
||||
cache *lru.Cache[string, int] // hashicorp/golang-lru/v2
|
||||
mu sync.RWMutex
|
||||
}
|
||||
```
|
||||
|
||||
Cache avoids re-counting unchanged messages during mid-loop re-estimation. Only new/modified messages hit tokenizer.
|
||||
|
||||
### Per-Message Overhead
|
||||
|
||||
```
|
||||
Per message: +4 tokens (role marker + separators)
|
||||
System message: +4 tokens additional
|
||||
Tool calls: name + JSON args counted as text
|
||||
Tool results: content text counted
|
||||
Images: model-specific (Claude: tile-based, GPT: similar)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Pipeline stage needs token count
|
||||
-> TokenCounter.CountMessages(model, messages)
|
||||
-> For each message:
|
||||
-> Hash(model, msg) -> check cache
|
||||
-> Cache miss: tokenizer.Encode(text) -> count -> store
|
||||
-> Sum + per-message overhead
|
||||
-> Return total
|
||||
```
|
||||
|
||||
### Affected Files (current codebase)
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `internal/agent/pruning.go` | Replace `EstimateHistoryTokens()` (chars/4) with `TokenCounter.CountMessages()` |
|
||||
| `internal/agent/loop_compact.go` | Replace token estimation in compaction trigger |
|
||||
| `internal/agent/loop.go:352-411` | Mid-loop budget calculation uses token counter |
|
||||
| `internal/agent/loop_history.go:624-699` | `maybeSummarize()` token estimation |
|
||||
| `internal/agent/loop_types.go` | `runState.overheadTokens` recalibration logic |
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `EstimateHistoryTokens()`, `EstimateTokensWithCalibration()` replaced
|
||||
- `overheadCalibrated` field in `runState` removed (overhead now computed accurately upfront)
|
||||
- `pruning.go` constants (`softTrimThreshold`, `hardClearThreshold`) stay but use accurate counts
|
||||
|
||||
---
|
||||
|
||||
## B. WorkspaceContext
|
||||
|
||||
### Interface Contract
|
||||
|
||||
```go
|
||||
// Package: internal/workspace
|
||||
|
||||
// Scope defines workspace access boundary.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopePersonal Scope = "personal" // single user, isolated
|
||||
ScopeTeam Scope = "team" // team context, shared or isolated
|
||||
ScopeDelegate Scope = "delegate" // delegated task, scoped access
|
||||
)
|
||||
|
||||
// WorkspaceContext is resolved ONCE at run start, immutable for the entire run.
|
||||
// Eliminates dual ctxWorkspace + ctxTeamWorkspace confusion.
|
||||
type WorkspaceContext struct {
|
||||
// ActivePath is THE path for all file operations (read/write/list/exec).
|
||||
// No other workspace path is accessible unless in ReadOnlyPaths.
|
||||
ActivePath string
|
||||
|
||||
// Scope describes the access boundary type.
|
||||
Scope Scope
|
||||
|
||||
// ReadOnlyPaths are additional paths the agent can read but NOT write.
|
||||
// Used for: delegator exports, shared reference dirs.
|
||||
ReadOnlyPaths []string
|
||||
|
||||
// SharedPath is the shared delegate area (read/write by both delegator + delegatee).
|
||||
// nil when not in delegation context.
|
||||
SharedPath *string
|
||||
|
||||
// TeamPath is the team workspace root (nil if not in team context).
|
||||
TeamPath *string
|
||||
|
||||
// MemoryScope determines memory isolation.
|
||||
// Defaults to workspace scope. "shared" = all users in agent see same memory.
|
||||
MemoryScope string
|
||||
|
||||
// KGScope determines knowledge graph isolation.
|
||||
// Defaults to workspace scope. "shared" = all users in agent see same KG.
|
||||
KGScope string
|
||||
|
||||
// OwnerID identifies who owns this workspace context (user ID or chat ID).
|
||||
OwnerID string
|
||||
|
||||
// EnforcementLabel is injected into system prompt verbatim.
|
||||
// e.g. "ENFORCED -- all file paths resolve here, absolute paths rejected"
|
||||
EnforcementLabel string
|
||||
}
|
||||
|
||||
// Resolver produces a WorkspaceContext from request parameters.
|
||||
// Called once at ContextStage. Result is immutable.
|
||||
type Resolver interface {
|
||||
Resolve(ctx context.Context, params ResolveParams) (*WorkspaceContext, error)
|
||||
}
|
||||
|
||||
// ResolveParams captures all inputs needed to determine workspace.
|
||||
type ResolveParams struct {
|
||||
AgentID string
|
||||
AgentType string // "open" | "predefined"
|
||||
UserID string
|
||||
ChatID string
|
||||
TenantID string
|
||||
PeerKind string // "direct" | "group"
|
||||
TeamID *string
|
||||
TeamConfig *TeamWorkspaceConfig // from teams table
|
||||
DelegateCtx *DelegateContext // from delegation task
|
||||
BaseDir string // global workspace root
|
||||
Sharing *store.WorkspaceSharingConfig
|
||||
}
|
||||
|
||||
// TeamWorkspaceConfig maps to team.settings JSON.
|
||||
type TeamWorkspaceConfig struct {
|
||||
SharedWorkspace bool `json:"shared_workspace"`
|
||||
WorkspacePath string `json:"workspace_path,omitempty"`
|
||||
}
|
||||
|
||||
// DelegateContext carries delegation-specific workspace overrides.
|
||||
type DelegateContext struct {
|
||||
LinkID string
|
||||
SharedPath string
|
||||
ExportPaths []string // read-only exports from delegator
|
||||
}
|
||||
```
|
||||
|
||||
### Resolution Rules
|
||||
|
||||
```
|
||||
1. Solo DM, not shared:
|
||||
ActivePath = {baseDir}/{tenantID}/{agentID}/{userID}
|
||||
Scope = personal
|
||||
|
||||
2. Solo DM, shared workspace:
|
||||
ActivePath = {baseDir}/{tenantID}/{agentID}
|
||||
Scope = personal (shared files, memory still per-user unless overridden)
|
||||
|
||||
3. Team dispatch (member executing task):
|
||||
ActivePath = {baseDir}/{tenantID}/teams/{teamID}/{chatID}
|
||||
Scope = team
|
||||
TeamPath = &ActivePath
|
||||
|
||||
4. Team inbound (leader receiving message):
|
||||
ActivePath = {baseDir}/{tenantID}/{agentID}/{userID}
|
||||
Scope = personal
|
||||
TeamPath = &teamWorkspacePath (read-only reference)
|
||||
|
||||
5. Delegate:
|
||||
ActivePath = own workspace
|
||||
Scope = delegate
|
||||
SharedPath = &sharedDelegatePath
|
||||
ReadOnlyPaths = delegator exports
|
||||
|
||||
6. Group chat:
|
||||
ActivePath = {baseDir}/{tenantID}/{agentID}/{chatID}
|
||||
Scope = personal
|
||||
```
|
||||
|
||||
### System Prompt Template Section
|
||||
|
||||
```
|
||||
## Workspace
|
||||
|
||||
Active workspace: {{.ActivePath}} ({{.Scope}})
|
||||
{{if .EnforcementLabel}}[{{.EnforcementLabel}}]{{end}}
|
||||
{{if .TeamPath}}Team workspace: {{.TeamPath}} (accessible via team file tools){{end}}
|
||||
{{if .ReadOnlyPaths}}Read-only paths: {{range .ReadOnlyPaths}}- {{.}}
|
||||
{{end}}{{end}}
|
||||
|
||||
Context files (SOUL.md, USER.md, MEMORY.md): managed by system, read/write intercepted.
|
||||
{{if eq .Scope "team"}}This is your active workspace. Personal workspace not accessible during this task.{{end}}
|
||||
```
|
||||
|
||||
### Affected Files
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `internal/agent/loop_context.go:110-176` | Replace workspace resolution with `Resolver.Resolve()` |
|
||||
| `internal/tools/context_keys.go` | Remove `ctxWorkspace`, `ctxTeamWorkspace`, `effectiveRestrict()` |
|
||||
| `internal/tools/filesystem.go:308-340` | `resolvePathWithAllowed()` reads from `WorkspaceContext` |
|
||||
| `internal/tools/filesystem_write.go` | Same |
|
||||
| `internal/tools/shell.go:243-267` | Working dir from `WorkspaceContext.ActivePath` |
|
||||
| `internal/agent/systemprompt.go:498-518` | Replace workspace section with template |
|
||||
| `internal/agent/systemprompt_sections.go:503-520` | Merge team workspace section into workspace template |
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `ctxWorkspace` / `ctxTeamWorkspace` context keys removed
|
||||
- `effectiveRestrict()` removed (always true, now implicit via WorkspaceContext)
|
||||
- `WithToolWorkspace()` / `WithToolTeamWorkspace()` replaced by single `WithWorkspaceContext()`
|
||||
- `tools.WithRestrictToWorkspace()` removed (enforcement = default)
|
||||
|
||||
---
|
||||
|
||||
## C. EventBus Redesign
|
||||
|
||||
### Interface Contract
|
||||
|
||||
```go
|
||||
// Package: internal/eventbus (new package, replaces internal/bus for domain events)
|
||||
// Note: internal/bus MessageBus retained for channel message routing (InboundMessage/OutboundMessage).
|
||||
|
||||
// DomainEvent is a typed event with metadata for consolidation pipeline.
|
||||
type DomainEvent struct {
|
||||
ID string // UUID v7 for ordering
|
||||
Type EventType
|
||||
SourceID string // dedup key (e.g. session key, run ID)
|
||||
TenantID string
|
||||
AgentID string
|
||||
UserID string
|
||||
Timestamp time.Time
|
||||
Payload any // typed per EventType (see below)
|
||||
}
|
||||
|
||||
// EventType identifies the event category.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventSessionCompleted EventType = "session.completed"
|
||||
EventEpisodicCreated EventType = "episodic.created"
|
||||
EventEntityUpserted EventType = "entity.upserted"
|
||||
EventMemoryLint EventType = "memory.lint"
|
||||
EventRunCompleted EventType = "run.completed"
|
||||
EventToolExecuted EventType = "tool.executed"
|
||||
)
|
||||
|
||||
// Typed payloads -- one per EventType.
|
||||
|
||||
type SessionCompletedPayload struct {
|
||||
SessionKey string
|
||||
MessageCount int
|
||||
TokensUsed int
|
||||
Summary string // compaction summary if available
|
||||
}
|
||||
|
||||
type EpisodicCreatedPayload struct {
|
||||
EpisodicID string
|
||||
SessionKey string
|
||||
Summary string
|
||||
KeyEntities []string // entity names for downstream extraction
|
||||
}
|
||||
|
||||
type EntityUpsertedPayload struct {
|
||||
EntityIDs []string // newly upserted entity UUIDs
|
||||
}
|
||||
|
||||
type MemoryLintPayload struct {
|
||||
Scope string // "agent" or "user"
|
||||
}
|
||||
|
||||
type RunCompletedPayload struct {
|
||||
RunID string
|
||||
Iterations int
|
||||
TokensUsed int
|
||||
ToolCalls int
|
||||
LoopKilled bool
|
||||
}
|
||||
|
||||
type ToolExecutedPayload struct {
|
||||
ToolName string
|
||||
Duration time.Duration
|
||||
Success bool
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// DomainEventBus manages typed event publishing + worker subscriptions.
|
||||
type DomainEventBus interface {
|
||||
// Publish enqueues an event for async processing.
|
||||
// Non-blocking: returns immediately.
|
||||
Publish(event DomainEvent)
|
||||
|
||||
// Subscribe registers a worker for a specific event type.
|
||||
// handler called in worker pool goroutine.
|
||||
// Returns unsubscribe function.
|
||||
Subscribe(eventType EventType, handler DomainEventHandler) func()
|
||||
|
||||
// Start launches worker pool. Must be called before Publish.
|
||||
Start(ctx context.Context)
|
||||
|
||||
// Drain waits for all queued events to be processed. For graceful shutdown.
|
||||
Drain(timeout time.Duration) error
|
||||
}
|
||||
|
||||
type DomainEventHandler func(ctx context.Context, event DomainEvent) error
|
||||
```
|
||||
|
||||
### Worker Pool Design
|
||||
|
||||
```go
|
||||
// Config for the domain event bus.
|
||||
type Config struct {
|
||||
QueueSize int // buffered channel capacity (default 1000)
|
||||
WorkerCount int // goroutines per event type (default 2)
|
||||
RetryAttempts int // retry on handler error (default 3)
|
||||
RetryDelay time.Duration // backoff base (default 1s, exponential)
|
||||
}
|
||||
```
|
||||
|
||||
Lane-based concurrency: each EventType gets its own worker pool. Events of different types processed in parallel. Events of same type processed sequentially per-worker (but multiple workers can process different events of same type concurrently).
|
||||
|
||||
### Idempotency
|
||||
|
||||
Dedup by `SourceID`: events with same SourceID + EventType processed at most once. Implementation: in-memory set with TTL (1h). If event bus restarts, re-processing is safe because all handlers are idempotent.
|
||||
|
||||
```go
|
||||
// dedup check before handler dispatch
|
||||
func (b *bus) dispatch(event DomainEvent) {
|
||||
key := event.Type + ":" + event.SourceID
|
||||
if b.seen.Has(key) {
|
||||
return // already processed
|
||||
}
|
||||
b.seen.Set(key, time.Now().Add(b.dedupTTL))
|
||||
// dispatch to handler...
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
| Producer | Event | Consumer |
|
||||
|----------|-------|----------|
|
||||
| Pipeline ObserveStage | `run.completed` | Metrics, tracing |
|
||||
| Pipeline ObserveStage | `tool.executed` | Self-evolution metrics |
|
||||
| Session close / compaction | `session.completed` | Episodic summary worker |
|
||||
| Episodic worker | `episodic.created` | Semantic extraction worker |
|
||||
| KG ingest | `entity.upserted` | Dedup check worker |
|
||||
| Cron scheduler | `memory.lint` | Memory lint worker |
|
||||
|
||||
### Affected Files
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `internal/bus/types.go` | Retained for channel routing. DomainEvent types in new package |
|
||||
| `internal/bus/bus.go` | Retained. New `internal/eventbus/` package alongside |
|
||||
| `internal/agent/loop_run.go` | Publish `run.completed` after finalize |
|
||||
| `internal/agent/loop_tools.go` | Publish `tool.executed` per tool call |
|
||||
| `internal/agent/loop_history.go:624-699` | Publish `session.completed` in `maybeSummarize()` |
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- None for existing `bus.MessageBus` (retained)
|
||||
- New `internal/eventbus` package added
|
||||
- Agent loop gains `DomainEventBus` dependency (injected via `Loop` struct)
|
||||
|
||||
---
|
||||
|
||||
## D. ProviderAdapter Interface
|
||||
|
||||
### Interface Contract
|
||||
|
||||
```go
|
||||
// Package: internal/providers (extends existing package)
|
||||
|
||||
// ProviderCapabilities declares what a provider supports.
|
||||
// Queried by pipeline to choose code paths (streaming vs non-streaming, etc.)
|
||||
type ProviderCapabilities struct {
|
||||
Streaming bool // supports ChatStream()
|
||||
ToolCalling bool // supports tools in request
|
||||
StreamWithTools bool // can stream while tool calls are in-flight (false for DashScope)
|
||||
Thinking bool // supports extended thinking / reasoning
|
||||
Vision bool // supports image inputs
|
||||
CacheControl bool // supports cache_control blocks (Anthropic)
|
||||
MaxContextWindow int // default context window for default model
|
||||
TokenizerID string // for tokencount package mapping
|
||||
}
|
||||
|
||||
// ProviderAdapter transforms between internal wire format and provider-specific format.
|
||||
// Each provider implements this to isolate quirks from the pipeline loop.
|
||||
type ProviderAdapter interface {
|
||||
// ToRequest converts internal ChatRequest to provider-specific wire bytes.
|
||||
// Handles: schema transforms, thinking passback, cache control injection.
|
||||
ToRequest(req ChatRequest) ([]byte, http.Header, error)
|
||||
|
||||
// FromResponse converts provider-specific response bytes to internal ChatResponse.
|
||||
// Handles: thinking extraction, usage normalization, finish reason mapping.
|
||||
FromResponse(data []byte) (*ChatResponse, error)
|
||||
|
||||
// FromStreamChunk converts a single SSE chunk to internal StreamChunk.
|
||||
// Returns nil if chunk should be skipped (keep-alive, metadata).
|
||||
FromStreamChunk(data []byte) (*StreamChunk, error)
|
||||
|
||||
// Capabilities returns static capability declaration.
|
||||
Capabilities() ProviderCapabilities
|
||||
|
||||
// Name returns provider identifier.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// CapabilitiesAware is optionally implemented by Provider.
|
||||
// Pipeline checks this to choose code path.
|
||||
type CapabilitiesAware interface {
|
||||
Capabilities() ProviderCapabilities
|
||||
}
|
||||
```
|
||||
|
||||
### Adapter Registry
|
||||
|
||||
```go
|
||||
// AdapterRegistry maps provider names to adapter factories.
|
||||
// Enables plugin-style provider registration.
|
||||
type AdapterRegistry struct {
|
||||
mu sync.RWMutex
|
||||
adapters map[string]AdapterFactory
|
||||
}
|
||||
|
||||
type AdapterFactory func(cfg ProviderConfig) (ProviderAdapter, error)
|
||||
|
||||
// Register adds a provider adapter. Called at init() or runtime.
|
||||
func (r *AdapterRegistry) Register(name string, factory AdapterFactory) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.adapters[name] = factory
|
||||
}
|
||||
|
||||
// Get returns adapter for provider name. Error if not registered.
|
||||
func (r *AdapterRegistry) Get(name string, cfg ProviderConfig) (ProviderAdapter, error) {
|
||||
r.mu.RLock()
|
||||
factory, ok := r.adapters[name]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown provider: %s", name)
|
||||
}
|
||||
return factory(cfg)
|
||||
}
|
||||
```
|
||||
|
||||
### What Moves to Adapter
|
||||
|
||||
| Currently in loop/provider | Moves to adapter |
|
||||
|---------------------------|-----------------|
|
||||
| `RawAssistantContent` passback (Anthropic thinking blocks) | `AnthropicAdapter.ToRequest()` |
|
||||
| `ThinkingSignature` accumulation | `AnthropicAdapter.FromStreamChunk()` |
|
||||
| DashScope non-streaming fallback for tool calls | `DashScopeAdapter.Capabilities().StreamWithTools = false` |
|
||||
| Codex `Phase` field injection | `CodexAdapter.ToRequest()` / `FromResponse()` |
|
||||
| Schema normalization (tool definitions → provider format) | Each adapter's `ToRequest()` |
|
||||
| Usage normalization (different field names) | Each adapter's `FromResponse()` |
|
||||
|
||||
### Pipeline Usage
|
||||
|
||||
```
|
||||
ThinkStage:
|
||||
1. caps := provider.Capabilities() // if CapabilitiesAware
|
||||
2. If caps.StreamWithTools == false && len(tools) > 0:
|
||||
-> use Chat() not ChatStream()
|
||||
3. If caps.Thinking:
|
||||
-> inject thinking options
|
||||
4. Call Chat/ChatStream as before (Provider interface unchanged)
|
||||
5. Provider internally uses its adapter for wire transforms
|
||||
```
|
||||
|
||||
Important: `providers.Provider` interface UNCHANGED. `ProviderAdapter` is internal to each provider implementation. Pipeline queries capabilities via optional `CapabilitiesAware` interface on the provider.
|
||||
|
||||
### Affected Files
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `internal/providers/types.go` | Add `ProviderCapabilities`, `CapabilitiesAware` |
|
||||
| `internal/providers/anthropic.go` | Extract wire transforms to `AnthropicAdapter` |
|
||||
| `internal/providers/openai.go` | Extract wire transforms to `OpenAIAdapter` |
|
||||
| `internal/providers/dashscope.go` | `StreamWithTools = false` in capabilities |
|
||||
| `internal/providers/codex.go` | Phase handling in adapter |
|
||||
| `internal/agent/loop.go:217-323` | ThinkStage queries capabilities instead of provider-type checks |
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `ThinkingCapable` interface superseded by `CapabilitiesAware` (backward compatible -- both can coexist during transition)
|
||||
- Provider-specific branching in loop.go removed (replaced by capability checks)
|
||||
- No external API changes
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- F1: Token counting accurate within 5% of provider-reported usage
|
||||
- F2: WorkspaceContext resolves all 6 scenarios correctly (solo DM, shared, team dispatch, team inbound, delegate, group)
|
||||
- F3: DomainEventBus processes events within 100ms p99 (in-process)
|
||||
- F4: Provider capabilities queryable without LLM call
|
||||
|
||||
### Non-Functional
|
||||
- NF1: TokenCounter cache hit rate >90% in typical session (messages rarely change)
|
||||
- NF2: WorkspaceContext immutable after creation (no data races)
|
||||
- NF3: EventBus handles 1000 events/sec sustained (consolidation pipeline load)
|
||||
- NF4: All 4 components have zero cross-dependencies (can implement in parallel)
|
||||
|
||||
## Design Steps
|
||||
|
||||
1. Define `TokenCounter` interface + `ModelRegistry` mapping table
|
||||
2. Implement `cachedCounter` with tiktoken-go + LRU cache
|
||||
3. Define `WorkspaceContext` struct + `Resolver` interface
|
||||
4. Implement resolver with resolution rules table
|
||||
5. Define `DomainEvent` types + `DomainEventBus` interface
|
||||
6. Implement worker pool with lane-based concurrency + dedup
|
||||
7. Define `ProviderCapabilities` + `CapabilitiesAware` interface
|
||||
8. Define `AdapterRegistry` for plugin registration
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] A1: TokenCounter interface definition — `internal/tokencount/token_counter.go`
|
||||
- [x] A2: ModelRegistry with prefix-based mapping — `internal/tokencount/token_counter.go`
|
||||
- [x] A3: Per-message hash cache design — documented in phase, cache impl deferred to implementation
|
||||
- [x] A4: Fallback strategy for unknown models — `internal/tokencount/fallback_counter.go`
|
||||
- [x] B1: WorkspaceContext struct definition — `internal/workspace/workspace_context.go`
|
||||
- [x] B2: Resolver interface + ResolveParams — `internal/workspace/workspace_context.go`
|
||||
- [x] B3: Resolution rules for all 6 scenarios — documented in phase design
|
||||
- [x] B4: System prompt template section for workspace — documented in phase design
|
||||
- [x] B5: Enforcement label design — EnforcementLabel field in WorkspaceContext
|
||||
- [x] C1: DomainEvent type definitions — `internal/eventbus/event_types.go`
|
||||
- [x] C2: Typed payload structs for each event — `internal/eventbus/event_types.go`
|
||||
- [x] C3: DomainEventBus interface — `internal/eventbus/domain_event_bus.go`
|
||||
- [x] C4: Worker pool config + lane-based concurrency design — `internal/eventbus/domain_event_bus.go`
|
||||
- [x] C5: Idempotency / dedup strategy — documented in phase design (SourceID-based)
|
||||
- [x] D1: ProviderCapabilities struct — `internal/providers/capabilities.go`
|
||||
- [x] D2: CapabilitiesAware optional interface — `internal/providers/capabilities.go`
|
||||
- [x] D3: AdapterRegistry for plugin registration — `internal/providers/adapter_registry.go`
|
||||
- [x] D4: Document what moves from loop to each adapter — documented in phase design
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- All 4 interfaces defined with complete method signatures
|
||||
- Each interface has zero imports from the other 3
|
||||
- WorkspaceContext resolution rules cover all 6 scenarios
|
||||
- Token counting accuracy validated against Anthropic/OpenAI reported usage
|
||||
- EventBus worker pool supports lane-based concurrency with configurable workers
|
||||
- Provider capabilities capture all known provider quirks (DashScope, Codex, Anthropic)
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| tiktoken-go tokenizer not exact match for Claude models | MEDIUM | Claude uses custom tokenizer, cl100k_base is approximation. Validate against API usage. Accept 5% error. |
|
||||
| WorkspaceContext missing edge case for new workspace scenario | LOW | Resolution rules table is explicit. Add new row when new scenario appears. |
|
||||
| EventBus in-process limitation (single instance, no persistence) | LOW | Sufficient for v3.0. External bus (Redis streams) can implement same interface later. |
|
||||
| ProviderAdapter abstraction leaks (provider needs loop context) | MEDIUM | Keep adapter stateless. Pass all needed data via `ChatRequest` fields. |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- WorkspaceContext enforcement is security-critical: `ActivePath` must be validated (no path traversal)
|
||||
- Existing `resolvePath()` security (symlink, hardlink checks) preserved -- WorkspaceContext provides the boundary, not the validation
|
||||
- EventBus handlers must not leak tenant data across boundaries (TenantID in every event)
|
||||
- TokenCounter is read-only, no security concerns
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 2 (Pipeline Loop) consumes all 4 interfaces
|
||||
- Phase 3 (Memory & KG) consumes TokenCounter + DomainEventBus
|
||||
- Phase 4 (System Integration) consumes WorkspaceContext + ProviderCapabilities
|
||||
@@ -1,653 +0,0 @@
|
||||
# Phase 2: Pipeline Loop Design
|
||||
|
||||
## Context Links
|
||||
|
||||
- Brainstorm: `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md` (decision 1)
|
||||
- Loop deep dive: `plans/reports/Explore-260406-GoClaw-Agent-Loop-v3-Deep-Dive.md` (all sections)
|
||||
- Phase 1 dependencies: `phase-01-foundation-interfaces.md` (TokenCounter, WorkspaceContext, EventBus, ProviderCapabilities)
|
||||
- Current loop: `internal/agent/loop.go` (804 lines), `loop_types.go`, `loop_context.go`, `loop_history.go`, `loop_compact.go`, `pruning.go`, `loop_tools.go`, `loop_run.go`
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** P1 (core execution engine, blocks phases 3-6)
|
||||
- **Status:** Complete
|
||||
- **Effort:** 8h design
|
||||
- **Depends on:** Phase 1 (all 4 foundation interfaces)
|
||||
|
||||
Replace monolithic `runLoop()` (804 lines, 20+ field `runState`) with a pipeline of pluggable stages. Each stage owns a substage of state, is independently testable, and communicates via a shared `RunState` struct.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Current loop has 7 exit conditions scattered across 800 lines. Pipeline model makes each exit condition a stage responsibility.
|
||||
- `runState` has 20+ fields mixing concerns (loop control, output accumulation, crash safety, bootstrap detection, team task tracking, skill evolution). Split into typed substates.
|
||||
- Mid-loop compaction (lines 352-411) interleaves with think phase. Pipeline separates this into `PruneStage` between think and tool.
|
||||
- Parallel tool execution already well-isolated (goroutine per tool, results collected via channel). Wrapping in `ToolStage` is clean.
|
||||
- Memory flush currently triggers inside `maybeSummarize()` which runs post-loop. Moving to `MemoryFlushStage` pre-compaction aligns with 3-tier memory design.
|
||||
|
||||
---
|
||||
|
||||
## A. Stage Interface
|
||||
|
||||
```go
|
||||
// Package: internal/pipeline
|
||||
|
||||
// Stage is a single step in the agent pipeline.
|
||||
// Stages are stateless -- all mutable state lives in RunState.
|
||||
type Stage interface {
|
||||
// Name returns a human-readable identifier for logging/tracing.
|
||||
Name() string
|
||||
|
||||
// Execute performs the stage's work. Returns error to abort pipeline.
|
||||
// Stages modify RunState in place.
|
||||
Execute(ctx context.Context, state *RunState) error
|
||||
}
|
||||
|
||||
// StageResult signals how the pipeline should proceed after a stage.
|
||||
type StageResult int
|
||||
|
||||
const (
|
||||
Continue StageResult = iota // proceed to next stage
|
||||
BreakLoop // exit iteration loop (normal completion)
|
||||
AbortRun // abort entire run (error/kill)
|
||||
)
|
||||
|
||||
// StageWithResult extends Stage to control pipeline flow.
|
||||
// If a stage does not implement this, pipeline assumes Continue.
|
||||
type StageWithResult interface {
|
||||
Stage
|
||||
Result() StageResult
|
||||
}
|
||||
```
|
||||
|
||||
## B. RunState Redesign
|
||||
|
||||
Split current 20+ field `runState` into typed substates. Each substate grouped by owning stage.
|
||||
|
||||
```go
|
||||
// RunState is the shared mutable state for a single pipeline run.
|
||||
// Passed by pointer through all stages.
|
||||
type RunState struct {
|
||||
// Identity (set once at pipeline start, immutable during run)
|
||||
Request *agent.RunRequest
|
||||
Workspace *workspace.WorkspaceContext
|
||||
Model string
|
||||
Provider providers.Provider
|
||||
|
||||
// Message buffer (read/write by multiple stages)
|
||||
Messages *MessageBuffer
|
||||
|
||||
// Per-stage substates
|
||||
Context ContextState
|
||||
Think ThinkState
|
||||
Prune PruneState
|
||||
Tool ToolState
|
||||
Observe ObserveState
|
||||
Compact CompactState
|
||||
Evolution EvolutionState
|
||||
|
||||
// Cross-cutting concerns
|
||||
Iteration int
|
||||
RunID string
|
||||
ExitCode StageResult
|
||||
}
|
||||
|
||||
// MessageBuffer wraps the message list with append/replace semantics.
|
||||
// Thread-safe for read; write only from owning stage (sequential pipeline).
|
||||
type MessageBuffer struct {
|
||||
system providers.Message // system prompt (rebuilt by ContextStage)
|
||||
history []providers.Message // conversation history
|
||||
pending []providers.Message // new messages this run (flushed at checkpoint)
|
||||
}
|
||||
|
||||
func (mb *MessageBuffer) All() []providers.Message { /* system + history + pending */ }
|
||||
func (mb *MessageBuffer) AppendPending(msg providers.Message) { /* ... */ }
|
||||
func (mb *MessageBuffer) FlushPending() []providers.Message { /* move pending to history */ }
|
||||
func (mb *MessageBuffer) ReplaceHistory(msgs []providers.Message) { /* post-compaction */ }
|
||||
```
|
||||
|
||||
### Substates
|
||||
|
||||
```go
|
||||
// ContextState: owned by ContextStage, read by ThinkStage
|
||||
type ContextState struct {
|
||||
ContextFiles []bootstrap.ContextFile
|
||||
SkillsSummary string
|
||||
TeamContext *TeamContextInfo // nil if not in team
|
||||
HadBootstrap bool
|
||||
OverheadTokens int // system prompt + context files token count (accurate via TokenCounter)
|
||||
}
|
||||
|
||||
// ThinkState: owned by ThinkStage
|
||||
type ThinkState struct {
|
||||
LastResponse *providers.ChatResponse
|
||||
TotalUsage providers.Usage
|
||||
TruncRetries int // consecutive truncation retries (max 3)
|
||||
StreamingActive bool // true during active stream
|
||||
}
|
||||
|
||||
// PruneState: owned by PruneStage
|
||||
type PruneState struct {
|
||||
MidLoopCompacted bool // true after first in-loop compaction
|
||||
HistoryTokens int // last computed history token count
|
||||
HistoryBudget int // contextWindow * maxHistoryShare
|
||||
}
|
||||
|
||||
// ToolState: owned by ToolStage
|
||||
type ToolState struct {
|
||||
LoopDetector *ToolLoopDetector
|
||||
TotalToolCalls int
|
||||
AsyncToolCalls []string // tool names that executed async (spawn)
|
||||
MediaResults []MediaResult // files produced by tools
|
||||
Deliverables []string // content for team task results
|
||||
LoopKilled bool // set when loop detector triggers critical
|
||||
}
|
||||
|
||||
// ObserveState: owned by ObserveStage
|
||||
type ObserveState struct {
|
||||
FinalContent string // accumulated response text
|
||||
FinalThinking string // reasoning output
|
||||
BlockReplies int
|
||||
LastBlockReply string
|
||||
}
|
||||
|
||||
// CompactState: owned by CheckpointStage + MemoryFlushStage
|
||||
type CompactState struct {
|
||||
CheckpointFlushedMsgs int
|
||||
MemoryFlushedThisCycle bool
|
||||
CompactionCount int // tracks compaction cycles
|
||||
}
|
||||
|
||||
// EvolutionState: owned by skill evolution nudge logic
|
||||
type EvolutionState struct {
|
||||
Nudge70Sent bool
|
||||
Nudge90Sent bool
|
||||
PostscriptSent bool
|
||||
BootstrapWrite bool // BOOTSTRAP.md write detected
|
||||
TeamTaskCreates int
|
||||
TeamTaskSpawns int
|
||||
}
|
||||
```
|
||||
|
||||
### Migration from current runState
|
||||
|
||||
| Current field | Moves to |
|
||||
|--------------|----------|
|
||||
| `loopDetector` | `ToolState.LoopDetector` |
|
||||
| `totalUsage` | `ThinkState.TotalUsage` |
|
||||
| `iteration` | `RunState.Iteration` |
|
||||
| `totalToolCalls` | `ToolState.TotalToolCalls` |
|
||||
| `finalContent` | `ObserveState.FinalContent` |
|
||||
| `finalThinking` | `ObserveState.FinalThinking` |
|
||||
| `asyncToolCalls` | `ToolState.AsyncToolCalls` |
|
||||
| `mediaResults` | `ToolState.MediaResults` |
|
||||
| `deliverables` | `ToolState.Deliverables` |
|
||||
| `pendingMsgs` | `MessageBuffer.pending` |
|
||||
| `blockReplies` | `ObserveState.BlockReplies` |
|
||||
| `lastBlockReply` | `ObserveState.LastBlockReply` |
|
||||
| `checkpointFlushedMsgs` | `CompactState.CheckpointFlushedMsgs` |
|
||||
| `midLoopCompacted` | `PruneState.MidLoopCompacted` |
|
||||
| `overheadTokens` | `ContextState.OverheadTokens` |
|
||||
| `overheadCalibrated` | REMOVED (overhead now accurate upfront via TokenCounter) |
|
||||
| `bootstrapWriteDetected` | `EvolutionState.BootstrapWrite` |
|
||||
| `teamTaskCreates/Spawns` | `EvolutionState.TeamTaskCreates/Spawns` |
|
||||
| `skillNudge*` | `EvolutionState.Nudge*` |
|
||||
| `loopKilled` | `ToolState.LoopKilled` |
|
||||
| `truncationRetries` | `ThinkState.TruncRetries` |
|
||||
|
||||
---
|
||||
|
||||
## C. Stage Catalog
|
||||
|
||||
### Setup Pipeline (runs once before iteration loop)
|
||||
|
||||
```
|
||||
1. ContextStage
|
||||
- Resolve WorkspaceContext via Resolver
|
||||
- Load + filter context files (bootstrap, per-user, team)
|
||||
- Build skills summary (BM25)
|
||||
- Build system prompt via template
|
||||
- Compute overhead tokens (TokenCounter)
|
||||
- Enrich input media
|
||||
- Inject team task reminders
|
||||
|
||||
Reads: RunState.Request, providers
|
||||
Writes: RunState.Workspace, ContextState, MessageBuffer.system
|
||||
|
||||
Replaces: injectContext() + buildMessages() + enrichInputMedia() + injectTeamTaskReminders()
|
||||
```
|
||||
|
||||
### Iteration Pipeline (runs per iteration, max N times)
|
||||
|
||||
```
|
||||
2. ThinkStage
|
||||
- Inject iteration budget nudges (70%, 90%)
|
||||
- Build per-iteration tool definitions (policy + bootstrap filters)
|
||||
- Build ChatRequest from MessageBuffer
|
||||
- Call LLM (Chat or ChatStream based on capabilities)
|
||||
- Emit events: chunk, thinking
|
||||
- Accumulate token usage
|
||||
- Handle truncation retries
|
||||
|
||||
Reads: MessageBuffer, ContextState, ProviderCapabilities
|
||||
Writes: ThinkState, MessageBuffer (append assistant message)
|
||||
Result: Continue (has tool calls) | BreakLoop (no tool calls, final content)
|
||||
|
||||
Replaces: loop.go lines 217-446
|
||||
|
||||
3. PruneStage
|
||||
- Compute history tokens via TokenCounter
|
||||
- Phase 1 @ 70% budget: pruneContextMessages (soft-trim + hard-clear)
|
||||
- Phase 2 @ 100% budget: compactMessagesInPlace (LLM summarization)
|
||||
- If still over budget after Phase 2: AbortRun
|
||||
|
||||
Reads: MessageBuffer, TokenCounter, context window
|
||||
Writes: PruneState, MessageBuffer (may replace history)
|
||||
Result: Continue | AbortRun (budget exceeded after compaction)
|
||||
|
||||
Replaces: loop.go lines 352-411, loop_compact.go, pruning.go
|
||||
|
||||
4. ToolStage
|
||||
- Extract tool calls from ThinkState.LastResponse
|
||||
- Generate unique tool call IDs
|
||||
- Single tool: sequential execution
|
||||
- Multiple tools: parallel goroutines, collect via channel, sort by index
|
||||
- Per-tool: policy check, lazy MCP activate, execute, process result
|
||||
- Loop detection: same-tool, read-only streak, same-result
|
||||
- Tool budget check (totalToolCalls > maxToolCalls)
|
||||
- Emit events: tool.call, tool.result
|
||||
|
||||
Reads: ThinkState.LastResponse, ToolExecutor, WorkspaceContext
|
||||
Writes: ToolState, MessageBuffer (append tool results)
|
||||
Result: Continue | BreakLoop (loop killed) | AbortRun (policy denied)
|
||||
|
||||
Replaces: loop.go lines 450-738, loop_tools.go
|
||||
|
||||
5. ObserveStage
|
||||
- Process tool results: extract deliverables, media
|
||||
- Drain InjectCh (mid-run message injection, Point A + Point B)
|
||||
- Check if final content ready (no tool calls + no injected messages)
|
||||
- Sanitize final content
|
||||
- Skill evolution postscript
|
||||
- Publish domain events: tool.executed, run metrics
|
||||
|
||||
Reads: ToolState, ThinkState, InjectCh
|
||||
Writes: ObserveState, MessageBuffer (may append injected messages)
|
||||
Result: Continue (more iterations needed) | BreakLoop (final content ready)
|
||||
|
||||
Replaces: loop.go lines 450-466 (no-tool path), finalize portions
|
||||
|
||||
6. CheckpointStage
|
||||
- Every N iterations (default 5): flush pending messages to session store
|
||||
- Crash safety: intermediate state persisted
|
||||
- Conditional: skip if iteration % N != 0
|
||||
|
||||
Reads: MessageBuffer.pending, SessionStore
|
||||
Writes: CompactState.CheckpointFlushedMsgs, MessageBuffer (clear pending)
|
||||
|
||||
Replaces: loop.go lines 748-761
|
||||
|
||||
7. MemoryFlushStage
|
||||
- Runs before compaction (when PruneStage detects budget pressure)
|
||||
- Triggered by PruneStage setting a flag, NOT by iteration count
|
||||
- Two-stage fallback: LLM flush -> extractive regex
|
||||
- Dedup guard: once per compaction cycle
|
||||
- Publishes: session.completed event (for episodic pipeline)
|
||||
|
||||
Reads: MessageBuffer, MemoryStore, compaction config
|
||||
Writes: CompactState.MemoryFlushedThisCycle
|
||||
|
||||
Replaces: memoryflush.go, extractive_memory.go
|
||||
```
|
||||
|
||||
### Post-Loop Pipeline (runs once after iteration loop exits)
|
||||
|
||||
```
|
||||
8. FinalizeStage
|
||||
- Sanitize final content (HTML entities, markdown cleanup)
|
||||
- Media dedup + size population
|
||||
- Flush remaining pending messages to session
|
||||
- Token accumulation to session metadata
|
||||
- Bootstrap auto-cleanup (delete BOOTSTRAP.md if write detected)
|
||||
- Post-run summarization (maybeSummarize)
|
||||
- Publish run.completed event
|
||||
- Construct RunResult
|
||||
|
||||
Reads: All substates
|
||||
Writes: RunResult (returned to caller)
|
||||
|
||||
Replaces: finalizeRun(), maybeSummarize()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D. Pipeline Orchestrator
|
||||
|
||||
```go
|
||||
// Pipeline orchestrates stage execution for a single agent run.
|
||||
type Pipeline struct {
|
||||
setup []Stage // runs once before loop
|
||||
iteration []Stage // runs per iteration
|
||||
finalize []Stage // runs once after loop
|
||||
|
||||
maxIterations int
|
||||
tokenCounter tokencount.TokenCounter
|
||||
eventBus eventbus.DomainEventBus
|
||||
}
|
||||
|
||||
// NewDefaultPipeline creates the standard agent pipeline.
|
||||
func NewDefaultPipeline(deps PipelineDeps) *Pipeline {
|
||||
return &Pipeline{
|
||||
setup: []Stage{
|
||||
NewContextStage(deps),
|
||||
},
|
||||
iteration: []Stage{
|
||||
NewThinkStage(deps),
|
||||
NewPruneStage(deps),
|
||||
NewToolStage(deps),
|
||||
NewObserveStage(deps),
|
||||
NewCheckpointStage(deps),
|
||||
},
|
||||
finalize: []Stage{
|
||||
NewFinalizeStage(deps),
|
||||
},
|
||||
maxIterations: deps.Config.MaxIterations,
|
||||
tokenCounter: deps.TokenCounter,
|
||||
eventBus: deps.EventBus,
|
||||
}
|
||||
}
|
||||
|
||||
// PipelineDeps bundles all dependencies injected into stages.
|
||||
type PipelineDeps struct {
|
||||
Provider providers.Provider
|
||||
TokenCounter tokencount.TokenCounter
|
||||
EventBus eventbus.DomainEventBus
|
||||
SessionStore store.SessionStore
|
||||
ToolExecutor tools.ToolExecutor
|
||||
ToolPolicy *tools.PolicyEngine
|
||||
SkillsLoader *skills.Loader
|
||||
MemoryStore store.MemoryStore
|
||||
WorkspaceResolver workspace.Resolver
|
||||
Config PipelineConfig
|
||||
}
|
||||
|
||||
type PipelineConfig struct {
|
||||
MaxIterations int
|
||||
MaxToolCalls int
|
||||
CheckpointInterval int // flush every N iterations (default 5)
|
||||
ContextWindow int
|
||||
MaxTokens int
|
||||
Compaction *config.CompactionConfig
|
||||
ContextPruning *config.ContextPruningConfig
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Flow
|
||||
|
||||
```go
|
||||
// Run executes the full pipeline for a single agent run.
|
||||
func (p *Pipeline) Run(ctx context.Context, req *agent.RunRequest) (*agent.RunResult, error) {
|
||||
state := NewRunState(req)
|
||||
|
||||
// 1. Setup pipeline (once)
|
||||
for _, stage := range p.setup {
|
||||
if err := stage.Execute(ctx, state); err != nil {
|
||||
return nil, fmt.Errorf("setup %s: %w", stage.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Iteration loop
|
||||
for state.Iteration = 0; state.Iteration < p.maxIterations; state.Iteration++ {
|
||||
for _, stage := range p.iteration {
|
||||
if err := stage.Execute(ctx, state); err != nil {
|
||||
return nil, fmt.Errorf("iter %d %s: %w", state.Iteration, stage.Name(), err)
|
||||
}
|
||||
|
||||
// Check if stage wants to control flow
|
||||
if swr, ok := stage.(StageWithResult); ok {
|
||||
switch swr.Result() {
|
||||
case BreakLoop:
|
||||
goto finalize
|
||||
case AbortRun:
|
||||
goto finalize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check context cancellation (/stop)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
finalize:
|
||||
// 3. Finalize pipeline (once)
|
||||
for _, stage := range p.finalize {
|
||||
if err := stage.Execute(ctx, state); err != nil {
|
||||
slog.Warn("finalize stage error", "stage", stage.Name(), "err", err)
|
||||
// finalize errors are logged, not fatal
|
||||
}
|
||||
}
|
||||
|
||||
return state.BuildResult(), nil
|
||||
}
|
||||
```
|
||||
|
||||
### Error Propagation
|
||||
|
||||
```
|
||||
Stage returns error:
|
||||
- Setup stage error -> abort run, return error
|
||||
- Iteration stage error -> abort run, still run finalize
|
||||
- Finalize stage error -> log warning, continue other finalize stages
|
||||
|
||||
Stage returns StageResult:
|
||||
- Continue -> next stage in iteration
|
||||
- BreakLoop -> skip remaining iteration stages, jump to finalize
|
||||
- AbortRun -> skip remaining stages, jump to finalize (with error flag)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Between Stages
|
||||
|
||||
```
|
||||
ContextStage
|
||||
writes: Workspace, ContextState, MessageBuffer.system
|
||||
|
|
||||
v
|
||||
ThinkStage
|
||||
reads: MessageBuffer.All(), ContextState.OverheadTokens
|
||||
writes: ThinkState.LastResponse, ThinkState.TotalUsage
|
||||
writes: MessageBuffer.AppendPending(assistant msg)
|
||||
|
|
||||
v
|
||||
PruneStage
|
||||
reads: MessageBuffer, TokenCounter, PruneState
|
||||
writes: PruneState.HistoryTokens
|
||||
may trigger: MemoryFlushStage (inline, before compaction)
|
||||
may write: MessageBuffer.ReplaceHistory(compacted)
|
||||
|
|
||||
v
|
||||
ToolStage
|
||||
reads: ThinkState.LastResponse.ToolCalls
|
||||
writes: ToolState.*, MessageBuffer.AppendPending(tool results)
|
||||
|
|
||||
v
|
||||
ObserveStage
|
||||
reads: ToolState, ThinkState, InjectCh
|
||||
writes: ObserveState.FinalContent (if done)
|
||||
may write: MessageBuffer.AppendPending(injected msgs)
|
||||
|
|
||||
v
|
||||
CheckpointStage
|
||||
reads: MessageBuffer.pending
|
||||
writes: SessionStore, CompactState.CheckpointFlushedMsgs
|
||||
|
|
||||
v (loop back to ThinkStage or exit to FinalizeStage)
|
||||
|
||||
FinalizeStage
|
||||
reads: All substates
|
||||
writes: RunResult
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MemoryFlushStage Integration with PruneStage
|
||||
|
||||
MemoryFlushStage is NOT a regular iteration stage. It is invoked inline by PruneStage when compaction is needed.
|
||||
|
||||
```go
|
||||
// PruneStage.Execute
|
||||
func (s *PruneStage) Execute(ctx context.Context, state *RunState) error {
|
||||
tokens := s.tokenCounter.CountMessages(state.Model, state.Messages.All())
|
||||
state.Prune.HistoryTokens = tokens
|
||||
|
||||
budget := s.contextWindow * 80 / 100 // 80% for history
|
||||
|
||||
// Phase 1: soft pruning at 70%
|
||||
if tokens > budget*70/100 {
|
||||
pruneContextMessages(state.Messages, s.config)
|
||||
tokens = s.tokenCounter.CountMessages(state.Model, state.Messages.All())
|
||||
}
|
||||
|
||||
// Phase 2: full compaction at 100%
|
||||
if tokens > budget {
|
||||
// Memory flush BEFORE compaction (saves memories before they're summarized away)
|
||||
if !state.Compact.MemoryFlushedThisCycle && s.memoryFlush != nil {
|
||||
s.memoryFlush.Execute(ctx, state) // inline call, not pipeline stage
|
||||
state.Compact.MemoryFlushedThisCycle = true
|
||||
}
|
||||
|
||||
state.Prune.MidLoopCompacted = true
|
||||
compactMessagesInPlace(state.Messages, s.compactionConfig)
|
||||
|
||||
// Re-check after compaction
|
||||
tokens = s.tokenCounter.CountMessages(state.Model, state.Messages.All())
|
||||
if tokens > budget {
|
||||
state.ExitCode = AbortRun
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- F1: Pipeline produces identical `RunResult` as current monolithic loop for same inputs
|
||||
- F2: All 7 exit conditions preserved: no-tool-calls, max iterations, truncation limit, loop kill, read-only streak, tool budget, ctx.Done
|
||||
- F3: Parallel tool execution behavior unchanged (goroutine per tool, sorted results)
|
||||
- F4: Mid-run injection (InjectCh) works at both Point A (after tools) and Point B (no tools)
|
||||
- F5: Checkpoint flush every N iterations, crash safety preserved
|
||||
|
||||
### Non-Functional
|
||||
- NF1: No measurable latency increase from stage dispatch overhead (stages are coarse-grained)
|
||||
- NF2: Each stage independently testable with mock RunState
|
||||
- NF3: Adding new stage requires only: implement Stage interface + insert in pipeline constructor
|
||||
- NF4: RunState substates compile-time typed (no `map[string]any`)
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to decompose
|
||||
|
||||
| Current file | Lines | Maps to stages |
|
||||
|-------------|-------|---------------|
|
||||
| `internal/agent/loop.go` | 804 | ThinkStage, ToolStage, ObserveStage, orchestrator |
|
||||
| `internal/agent/loop_run.go` | 245 | Pipeline.Run() entry point |
|
||||
| `internal/agent/loop_context.go` | 275 | ContextStage |
|
||||
| `internal/agent/loop_history.go` | 859 | ContextStage (buildMessages), FinalizeStage (maybeSummarize) |
|
||||
| `internal/agent/loop_compact.go` | 119 | PruneStage |
|
||||
| `internal/agent/pruning.go` | 327 | PruneStage |
|
||||
| `internal/agent/loop_tools.go` | ~180 | ToolStage |
|
||||
| `internal/agent/loop_tool_filter.go` | ~100 | ThinkStage (buildFilteredTools) |
|
||||
| `internal/agent/memoryflush.go` | ~150 | MemoryFlushStage |
|
||||
| `internal/agent/extractive_memory.go` | ~150 | MemoryFlushStage (fallback) |
|
||||
| `internal/agent/loop_types.go` | ~150 | RunState, substates |
|
||||
| `internal/agent/loop_input_media.go` | ~100 | ContextStage |
|
||||
| `internal/agent/loop_team_reminders.go` | ~60 | ContextStage |
|
||||
|
||||
### New files to create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/pipeline/stage.go` | Stage interface, StageResult, StageWithResult |
|
||||
| `internal/pipeline/run_state.go` | RunState + MessageBuffer + all substates |
|
||||
| `internal/pipeline/pipeline.go` | Pipeline orchestrator |
|
||||
| `internal/pipeline/context_stage.go` | ContextStage implementation |
|
||||
| `internal/pipeline/think_stage.go` | ThinkStage implementation |
|
||||
| `internal/pipeline/prune_stage.go` | PruneStage + inline MemoryFlush |
|
||||
| `internal/pipeline/tool_stage.go` | ToolStage implementation |
|
||||
| `internal/pipeline/observe_stage.go` | ObserveStage implementation |
|
||||
| `internal/pipeline/checkpoint_stage.go` | CheckpointStage implementation |
|
||||
| `internal/pipeline/finalize_stage.go` | FinalizeStage implementation |
|
||||
|
||||
## Design Steps
|
||||
|
||||
1. Define Stage interface + StageResult enum
|
||||
2. Design RunState with typed substates (map current fields)
|
||||
3. Define MessageBuffer with append/replace/flush semantics
|
||||
4. Design each stage's Execute signature and data flow
|
||||
5. Design Pipeline orchestrator with setup/iteration/finalize phases
|
||||
6. Map error propagation rules (setup fatal, iteration -> finalize, finalize logged)
|
||||
7. Design MemoryFlushStage inline integration with PruneStage
|
||||
8. Verify all 7 exit conditions map to StageResult values
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] A1: Stage interface + StageResult + StageWithResult — `internal/pipeline/stage.go`
|
||||
- [x] B1: RunState top-level struct — `internal/pipeline/run_state.go`
|
||||
- [x] B2: MessageBuffer with thread-safe read, owning-stage write — `internal/pipeline/run_state.go`
|
||||
- [x] B3: ContextState substate — `internal/pipeline/substates.go`
|
||||
- [x] B4: ThinkState substate — `internal/pipeline/substates.go`
|
||||
- [x] B5: PruneState substate — `internal/pipeline/substates.go`
|
||||
- [x] B6: ToolState substate — `internal/pipeline/substates.go`
|
||||
- [x] B7: ObserveState substate — `internal/pipeline/substates.go`
|
||||
- [x] B8: CompactState substate — `internal/pipeline/substates.go`
|
||||
- [x] B9: EvolutionState substate — `internal/pipeline/substates.go`
|
||||
- [x] C1: ContextStage design — documented in phase (impl deferred)
|
||||
- [x] C2: ThinkStage design — documented in phase (impl deferred)
|
||||
- [x] C3: PruneStage design — documented in phase (impl deferred)
|
||||
- [x] C4: ToolStage design — documented in phase (impl deferred)
|
||||
- [x] C5: ObserveStage design — documented in phase (impl deferred)
|
||||
- [x] C6: CheckpointStage design — documented in phase (impl deferred)
|
||||
- [x] C7: MemoryFlushStage design — documented in phase (impl deferred)
|
||||
- [x] C8: FinalizeStage design — documented in phase (impl deferred)
|
||||
- [x] D1: Pipeline orchestrator with 3-phase execution — `internal/pipeline/pipeline.go`
|
||||
- [x] D2: PipelineDeps dependency bundle — via NewPipeline() params
|
||||
- [x] D3: Error propagation rules — setup fatal, iteration fatal+finalize, finalize logged
|
||||
- [x] E1: Verify all 7 exit conditions preserved — documented in phase design
|
||||
- [x] E2: Map current runState fields to substates — documented in phase design
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Every line of current `loop.go` maps to exactly one stage
|
||||
- `runState` fields fully accounted for in substates (no orphans)
|
||||
- Pipeline.Run() produces same RunResult semantics as current Loop.Run()
|
||||
- Each stage can be tested with a constructed RunState (no Loop struct dependency)
|
||||
- MemoryFlushStage integrated with PruneStage without circular dependency
|
||||
- InjectCh drain preserved at both injection points
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Subtle behavior change from stage ordering | HIGH | Integration tests comparing v2 vs v3 output for same inputs. Run existing test suite. |
|
||||
| InjectCh timing sensitivity (Point A vs Point B) | MEDIUM | ObserveStage handles both points explicitly. Document injection semantics. |
|
||||
| MemoryFlushStage inline call breaks stage isolation | LOW | Acceptable trade-off: flush MUST happen before compaction. Document as intentional coupling. |
|
||||
| RunState too large (sum of substates) | LOW | Substates are small structs. Total < 500 bytes. No heap pressure. |
|
||||
| Stage interface too simple for complex stages | LOW | StageWithResult extension handles flow control. Further extension possible via StageWithCleanup etc. |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- ToolStage must preserve all existing security checks: policy engine, shell deny patterns, workspace restriction
|
||||
- ContextStage must preserve input guard scanning (injection pattern detection)
|
||||
- CheckpointStage flushes to session store with existing tenant scoping
|
||||
- No new attack surface introduced by pipeline refactor
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 3 (Memory & KG) adds MemoryFlushStage consumer for `session.completed` events
|
||||
- Phase 4 (System Integration) adds template-based system prompt in ContextStage
|
||||
- Phase 5 (Orchestration) may add DelegateStage or modify ToolStage for delegation
|
||||
@@ -1,797 +0,0 @@
|
||||
# Phase 3: Memory & KG Design
|
||||
|
||||
## Context Links
|
||||
|
||||
- Brainstorm: `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md` (decisions 2, 3, 4, 6, 7)
|
||||
- Research: `plans/reports/researcher-260406-1059-memory-context-kg-research.md`
|
||||
- Architecture: `plans/reports/Explore-260406-thorough-v3-architecture.md` (sections 1, 2, 8, 10)
|
||||
- Phase 1: `phase-01-foundation-interfaces.md` (TokenCounter, DomainEventBus)
|
||||
- Phase 2: `phase-02-pipeline-loop-design.md` (PruneStage, MemoryFlushStage, ObserveStage)
|
||||
- Current memory store: `internal/store/memory_store.go`
|
||||
- Current KG store: `internal/store/knowledge_graph_store.go`
|
||||
- Current KG migration: `migrations/000013_knowledge_graph.up.sql`
|
||||
- Current memory migration: `migrations/000001_init_schema.up.sql` (lines 163-192)
|
||||
|
||||
## Overview
|
||||
|
||||
- **Priority:** P1 (core cognitive capability)
|
||||
- **Status:** Complete
|
||||
- **Effort:** 8h design
|
||||
- **Depends on:** Phase 1 (TokenCounter for L0 budget, DomainEventBus for consolidation), Phase 2 (pipeline integration points)
|
||||
|
||||
Design 3-tier memory system, temporal KG, consolidation pipeline, L0/L1/L2 context tiering, and smart auto-inject retrieval. This phase transforms GoClaw from append-only memory to a curated, searchable, self-maintaining knowledge system.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Current memory is append-only daily .md files, never revisited. Contradictions accumulate, no consolidation, no dedup
|
||||
- KG extraction is synchronous (blocks loop). Dedup thresholds hardcoded (0.98/0.90). No temporal awareness -- stale facts never expire
|
||||
- Memory search is hybrid FTS+vector but flat -- no tier awareness, no relevance-based injection
|
||||
- Karpathy insight: memory should be curated wiki, not append-only log
|
||||
- OpenViking insight: L0/L1/L2 tiering saves 10x tokens vs full-context loading
|
||||
- Graphiti insight: temporal validity windows on KG facts enable contradiction resolution
|
||||
|
||||
---
|
||||
|
||||
## A. 3-Tier Memory Schema
|
||||
|
||||
### Tier 1: Working Memory
|
||||
|
||||
In context window. No new storage needed. Managed by MessageBuffer in pipeline.
|
||||
|
||||
- Content: recent 20-50 messages (conversation turns)
|
||||
- Lifecycle: per-turn, free (no persistence cost)
|
||||
- Size: up to 80% of context window budget
|
||||
- Pruned by: PruneStage (token-accurate via TokenCounter)
|
||||
|
||||
### Tier 2: Episodic Memory
|
||||
|
||||
New table. Session summaries + key interactions with pgvector embeddings.
|
||||
|
||||
```sql
|
||||
-- Migration: 000XXX_episodic_summaries.up.sql
|
||||
|
||||
CREATE TABLE episodic_summaries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
session_key VARCHAR(500) NOT NULL,
|
||||
|
||||
-- Content
|
||||
summary TEXT NOT NULL, -- LLM-generated session summary
|
||||
key_entities JSONB DEFAULT '[]', -- entity names extracted (for downstream KG)
|
||||
key_topics JSONB DEFAULT '[]', -- topic tags for BM25
|
||||
message_count INT NOT NULL DEFAULT 0, -- messages in summarized session
|
||||
token_count INT NOT NULL DEFAULT 0, -- total tokens in session
|
||||
|
||||
-- Embeddings for semantic search
|
||||
embedding vector(1536),
|
||||
tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', summary)) STORED,
|
||||
|
||||
-- L0 abstract (pre-computed, ~50 tokens)
|
||||
l0_abstract TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Lifecycle
|
||||
source_id VARCHAR(255) NOT NULL DEFAULT '', -- dedup key (session_key + compaction_count)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ, -- NULL = no expiry, default 90d from creation
|
||||
|
||||
UNIQUE(agent_id, user_id, source_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_episodic_scope ON episodic_summaries(agent_id, user_id);
|
||||
CREATE INDEX idx_episodic_tenant ON episodic_summaries(tenant_id);
|
||||
CREATE INDEX idx_episodic_created ON episodic_summaries(agent_id, user_id, created_at DESC);
|
||||
CREATE INDEX idx_episodic_expires ON episodic_summaries(expires_at) WHERE expires_at IS NOT NULL;
|
||||
CREATE INDEX idx_episodic_tsv ON episodic_summaries USING gin(tsv);
|
||||
CREATE INDEX idx_episodic_embedding ON episodic_summaries USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 50);
|
||||
```
|
||||
|
||||
### Tier 3: Semantic Memory
|
||||
|
||||
Existing KG tables (`kg_entities`, `kg_relations`) + temporal fields (section B). Facts extracted from episodic summaries via consolidation pipeline. Permanent, deduplicated.
|
||||
|
||||
No new table needed. Temporal fields added to existing tables.
|
||||
|
||||
### Tier Flow
|
||||
|
||||
```
|
||||
Working (Tier 1) Episodic (Tier 2) Semantic (Tier 3)
|
||||
[in context window] [episodic_summaries] [kg_entities + kg_relations]
|
||||
| | |
|
||||
|-- session end / compaction ------>| |
|
||||
| (session.completed event) | |
|
||||
| |-- extraction worker -------->|
|
||||
| | (episodic.created event) |
|
||||
| | |
|
||||
|<-- auto-inject L0 summaries ------| |
|
||||
|<-- memory_search L1/L2 ---------->| |
|
||||
|<-- kg_search ------------------------------------------->| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B. Temporal KG Schema
|
||||
|
||||
### Schema Changes
|
||||
|
||||
```sql
|
||||
-- Migration: 000XXX_kg_temporal.up.sql
|
||||
|
||||
-- Add temporal validity to entities
|
||||
ALTER TABLE kg_entities
|
||||
ADD COLUMN valid_from TIMESTAMPTZ DEFAULT NOW(),
|
||||
ADD COLUMN valid_until TIMESTAMPTZ; -- NULL = currently valid
|
||||
|
||||
-- Add temporal validity to relations
|
||||
ALTER TABLE kg_relations
|
||||
ADD COLUMN valid_from TIMESTAMPTZ DEFAULT NOW(),
|
||||
ADD COLUMN valid_until TIMESTAMPTZ;
|
||||
|
||||
-- Backfill existing data: valid_from = created_at, valid_until = NULL (current)
|
||||
UPDATE kg_entities SET valid_from = created_at WHERE valid_from IS NULL;
|
||||
UPDATE kg_relations SET valid_from = created_at WHERE valid_from IS NULL;
|
||||
|
||||
-- Partial index for current facts (most common query)
|
||||
CREATE INDEX idx_kg_entities_current ON kg_entities(agent_id, user_id)
|
||||
WHERE valid_until IS NULL;
|
||||
CREATE INDEX idx_kg_relations_current ON kg_relations(agent_id, user_id)
|
||||
WHERE valid_until IS NULL;
|
||||
|
||||
-- Add configurable dedup thresholds (per-agent, stored in agents.other_config JSONB)
|
||||
-- No schema change needed -- uses existing agents.other_config field:
|
||||
-- { "kg_dedup_auto_threshold": 0.98, "kg_dedup_flag_threshold": 0.90 }
|
||||
```
|
||||
|
||||
### Query Patterns
|
||||
|
||||
```sql
|
||||
-- Current facts (default, most queries)
|
||||
SELECT * FROM kg_entities
|
||||
WHERE agent_id = $1 AND user_id = $2 AND valid_until IS NULL;
|
||||
|
||||
-- Historical: what was true at a specific time?
|
||||
SELECT * FROM kg_entities
|
||||
WHERE agent_id = $1 AND user_id = $2
|
||||
AND valid_from <= $3
|
||||
AND (valid_until IS NULL OR valid_until >= $3);
|
||||
|
||||
-- Supersede a fact: new contradicting fact arrives
|
||||
UPDATE kg_entities SET valid_until = NOW(), updated_at = NOW()
|
||||
WHERE agent_id = $1 AND user_id = $2 AND external_id = $3 AND valid_until IS NULL;
|
||||
-- Then INSERT new entity with valid_from = NOW()
|
||||
```
|
||||
|
||||
### Contradiction Resolution
|
||||
|
||||
When extraction produces a fact that contradicts an existing current fact:
|
||||
|
||||
```
|
||||
1. Extractor produces: "User works at CompanyB" (confidence 0.85)
|
||||
2. Existing current fact: "User works at CompanyA" (confidence 0.90)
|
||||
3. If same external_id + same entity_type:
|
||||
a. Supersede old: SET valid_until = NOW() on CompanyA fact
|
||||
b. Insert new: CompanyB with valid_from = NOW()
|
||||
4. If different external_id but related via relation:
|
||||
a. Flag as dedup candidate (human review)
|
||||
```
|
||||
|
||||
### Configurable Thresholds
|
||||
|
||||
```go
|
||||
// Per-agent KG config (stored in agents.other_config JSONB)
|
||||
type KGConfig struct {
|
||||
DedupAutoThreshold float64 `json:"kg_dedup_auto_threshold"` // default 0.98
|
||||
DedupFlagThreshold float64 `json:"kg_dedup_flag_threshold"` // default 0.90
|
||||
ExtractionMinConf float64 `json:"kg_extraction_min_conf"` // default 0.75
|
||||
EnableTemporal bool `json:"kg_enable_temporal"` // default true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## C. Consolidation Pipeline
|
||||
|
||||
Event-driven, async, non-blocking. User never waits for consolidation.
|
||||
|
||||
### Event Flow
|
||||
|
||||
```
|
||||
session.completed
|
||||
|
|
||||
v
|
||||
EpisodicWorker
|
||||
|-- Summarize session messages via LLM
|
||||
|-- Extract key entities (names only, for downstream)
|
||||
|-- Generate L0 abstract (~1 sentence, ~50 tokens)
|
||||
|-- Store in episodic_summaries
|
||||
|-- Generate embedding (async)
|
||||
|-- Publish: episodic.created
|
||||
|
|
||||
v
|
||||
SemanticExtractionWorker
|
||||
|-- Read episodic summary
|
||||
|-- Run KG extractor on summary (not full session -- cheaper)
|
||||
|-- Upsert entities + relations with temporal fields
|
||||
|-- Publish: entity.upserted
|
||||
|
|
||||
v
|
||||
DedupWorker
|
||||
|-- Read newly upserted entity IDs
|
||||
|-- Compare embeddings against existing entities
|
||||
|-- Auto-merge (> auto_threshold + name match)
|
||||
|-- Flag candidates (> flag_threshold)
|
||||
|-- Handle temporal supersession for contradictions
|
||||
```
|
||||
|
||||
### Worker Implementations
|
||||
|
||||
```go
|
||||
// Package: internal/consolidation
|
||||
|
||||
// EpisodicWorker processes session.completed events.
|
||||
type EpisodicWorker struct {
|
||||
store store.EpisodicStore
|
||||
provider providers.Provider // for LLM summarization
|
||||
tokenCounter tokencount.TokenCounter
|
||||
eventBus eventbus.DomainEventBus
|
||||
}
|
||||
|
||||
func (w *EpisodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent) error {
|
||||
payload := event.Payload.(eventbus.SessionCompletedPayload)
|
||||
|
||||
// Skip if already processed (idempotent by source_id)
|
||||
sourceID := fmt.Sprintf("%s:%d", payload.SessionKey, payload.CompactionCount)
|
||||
if w.store.ExistsBySourceID(ctx, event.AgentID, event.UserID, sourceID) {
|
||||
return nil // already processed
|
||||
}
|
||||
|
||||
// 1. Summarize session
|
||||
summary, err := w.summarizeSession(ctx, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("summarize: %w", err)
|
||||
}
|
||||
|
||||
// 2. Extract key entities (lightweight -- name extraction, not full KG)
|
||||
entities := extractEntityNames(summary)
|
||||
|
||||
// 3. Generate L0 abstract
|
||||
l0 := generateL0Abstract(summary) // extractive: first sentence or topic sentence
|
||||
|
||||
// 4. Store
|
||||
ep := &store.EpisodicSummary{
|
||||
AgentID: event.AgentID,
|
||||
UserID: event.UserID,
|
||||
TenantID: event.TenantID,
|
||||
SessionKey: payload.SessionKey,
|
||||
Summary: summary,
|
||||
KeyEntities: entities,
|
||||
L0Abstract: l0,
|
||||
MessageCount: payload.MessageCount,
|
||||
TokenCount: payload.TokensUsed,
|
||||
SourceID: sourceID,
|
||||
ExpiresAt: timePtr(time.Now().Add(90 * 24 * time.Hour)), // 90d TTL
|
||||
}
|
||||
if err := w.store.Create(ctx, ep); err != nil {
|
||||
return fmt.Errorf("store episodic: %w", err)
|
||||
}
|
||||
|
||||
// 5. Publish for downstream
|
||||
w.eventBus.Publish(eventbus.DomainEvent{
|
||||
Type: eventbus.EventEpisodicCreated,
|
||||
AgentID: event.AgentID,
|
||||
UserID: event.UserID,
|
||||
Payload: eventbus.EpisodicCreatedPayload{
|
||||
EpisodicID: ep.ID,
|
||||
SessionKey: payload.SessionKey,
|
||||
Summary: summary,
|
||||
KeyEntities: entities,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Summarization Prompt (Episodic)
|
||||
|
||||
```
|
||||
Summarize this conversation session concisely. Focus on:
|
||||
- Key decisions made
|
||||
- Facts learned about the user or project
|
||||
- Tasks completed or in-progress
|
||||
- Important technical details
|
||||
- User preferences expressed
|
||||
|
||||
Output: 2-4 paragraph summary. Include entity names (people, projects, tools) explicitly.
|
||||
Do NOT include greetings, filler, or metadata.
|
||||
```
|
||||
|
||||
### L0 Abstract Generation
|
||||
|
||||
V3.0: extractive (first meaningful sentence of summary). V3.1+: LLM-generated.
|
||||
|
||||
```go
|
||||
// generateL0Abstract produces a ~50 token abstract from episodic summary.
|
||||
// V3.0: extractive (first sentence). Future: LLM-generated.
|
||||
func generateL0Abstract(summary string) string {
|
||||
// Split into sentences, take first non-trivial one
|
||||
sentences := splitSentences(summary)
|
||||
for _, s := range sentences {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > 20 { // skip very short fragments
|
||||
if len(s) > 200 { // truncate if too long
|
||||
s = s[:200] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
// Fallback: first 200 chars
|
||||
if len(summary) > 200 {
|
||||
return summary[:200] + "..."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D. L0/L1/L2 Context Tiering
|
||||
|
||||
### Layer Definitions
|
||||
|
||||
| Layer | Content | Token cost | When loaded | Storage |
|
||||
|-------|---------|-----------|-------------|---------|
|
||||
| **L0** | 1-sentence abstract | ~50 tokens | Always (auto-inject if relevant) | `episodic_summaries.l0_abstract` |
|
||||
| **L1** | Structured overview | ~200 tokens | When topic relevant | Generated on-demand, cached |
|
||||
| **L2** | Full content | Full | On-demand via tool call | `episodic_summaries.summary` (episodic) or original document (memory docs) |
|
||||
|
||||
### L1 Generation
|
||||
|
||||
On-demand when agent requests deeper context or auto-inject detects high relevance.
|
||||
|
||||
```go
|
||||
// L1Cache caches generated L1 overviews. LRU, 500 entries, 1h TTL.
|
||||
type L1Cache struct {
|
||||
cache *lru.Cache[string, string] // key: episodic_id or doc_path
|
||||
}
|
||||
|
||||
// GenerateL1 creates structured overview from episodic summary or memory doc.
|
||||
func (c *L1Cache) GenerateL1(id string, fullContent string) string {
|
||||
if cached, ok := c.cache.Get(id); ok {
|
||||
return cached
|
||||
}
|
||||
// Extractive: key bullet points from full content
|
||||
l1 := extractKeyPoints(fullContent, 200) // ~200 tokens budget
|
||||
c.cache.Add(id, l1)
|
||||
return l1
|
||||
}
|
||||
```
|
||||
|
||||
### L2 Access
|
||||
|
||||
Full content loaded via tool call. Agent calls `memory_search(query, depth="full")` or `memory_expand(id)`.
|
||||
|
||||
```go
|
||||
// memory_expand tool: loads L2 full content for a specific memory entry
|
||||
// Parameters: id (episodic ID or document path)
|
||||
// Returns: full content (summary or document)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E. Smart Auto-Inject Retrieval
|
||||
|
||||
Per-turn relevance check + L0 injection. Non-blocking, lightweight.
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
User message arrives (in ContextStage)
|
||||
|
|
||||
v
|
||||
1. Extract intent keywords from user message
|
||||
- Tokenize, remove stopwords, keep nouns/verbs/proper nouns
|
||||
- If message too short ("hello", "ok"): skip injection (no relevant context)
|
||||
|
|
||||
v
|
||||
2. BM25 + embedding relevance check against episodic index
|
||||
- BM25 on episodic_summaries.tsv (fast, FTS)
|
||||
- Embedding similarity on episodic_summaries.embedding (if available)
|
||||
- Merge scores (configurable weights)
|
||||
|
|
||||
v
|
||||
3. Filter by relevance threshold
|
||||
- Default: 0.3 (tunable per agent via agent config)
|
||||
- If no results above threshold: skip injection
|
||||
|
|
||||
v
|
||||
4. Inject "## Memory Context" section into system prompt
|
||||
- Max 5 entries, max ~200 tokens total (L0 summaries only)
|
||||
- Format per entry: "- {topic}: {L0 abstract} [use memory_search for details]"
|
||||
|
|
||||
v
|
||||
5. Agent sees hint, can call memory_search for L1/L2
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```go
|
||||
// Package: internal/memory
|
||||
|
||||
// AutoInjector checks relevance and produces L0 injection for system prompt.
|
||||
type AutoInjector interface {
|
||||
// Inject checks user message against memory index.
|
||||
// Returns formatted section for system prompt, or "" if nothing relevant.
|
||||
// Budget: max ~200 tokens of L0 summaries.
|
||||
Inject(ctx context.Context, params InjectParams) (string, error)
|
||||
}
|
||||
|
||||
type InjectParams struct {
|
||||
AgentID string
|
||||
UserID string
|
||||
TenantID string
|
||||
UserMessage string
|
||||
MaxEntries int // default 5
|
||||
MaxTokens int // default 200
|
||||
Threshold float64 // relevance threshold (default 0.3)
|
||||
}
|
||||
|
||||
// InjectResult for observability
|
||||
type InjectResult struct {
|
||||
Section string // formatted prompt section
|
||||
MatchCount int // total matches found
|
||||
Injected int // entries injected (after budget trim)
|
||||
TopScore float64 // highest relevance score
|
||||
}
|
||||
```
|
||||
|
||||
### System Prompt Section (generated by AutoInjector)
|
||||
|
||||
```
|
||||
## Memory Context
|
||||
|
||||
Relevant memories from past sessions (summaries only -- use memory_search for details):
|
||||
- Project Alpha: Discussed migration from PostgreSQL to CockroachDB, decided to stay with PG for now.
|
||||
- User preferences: Prefers Go over Python for backend, uses Neovim as primary editor.
|
||||
- Bug fix: Resolved auth token expiration issue in session middleware (2026-03-15).
|
||||
|
||||
Use `memory_search` tool to retrieve full details on any topic above.
|
||||
```
|
||||
|
||||
### Agent Config
|
||||
|
||||
```go
|
||||
// Per-agent memory config (extends existing config.MemoryConfig)
|
||||
type MemoryConfig struct {
|
||||
// Existing fields...
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// V3 additions
|
||||
AutoInjectEnabled bool `json:"auto_inject_enabled"` // default true
|
||||
AutoInjectThreshold float64 `json:"auto_inject_threshold"` // default 0.3
|
||||
AutoInjectMaxTokens int `json:"auto_inject_max_tokens"` // default 200
|
||||
EpisodicTTLDays int `json:"episodic_ttl_days"` // default 90 (0 = no expiry)
|
||||
ConsolidationEnabled bool `json:"consolidation_enabled"` // default true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## F. MemoryStore v3 Interface
|
||||
|
||||
Extends existing `MemoryStore` with tier-aware operations.
|
||||
|
||||
```go
|
||||
// Package: internal/store
|
||||
|
||||
// EpisodicSummary represents a Tier 2 episodic memory entry.
|
||||
type EpisodicSummary struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
UserID string `json:"user_id"`
|
||||
SessionKey string `json:"session_key"`
|
||||
Summary string `json:"summary"`
|
||||
KeyEntities []string `json:"key_entities"`
|
||||
KeyTopics []string `json:"key_topics"`
|
||||
L0Abstract string `json:"l0_abstract"`
|
||||
MessageCount int `json:"message_count"`
|
||||
TokenCount int `json:"token_count"`
|
||||
SourceID string `json:"source_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// EpisodicSearchResult is a search hit with tier indicator.
|
||||
type EpisodicSearchResult struct {
|
||||
EpisodicID string `json:"episodic_id"`
|
||||
L0Abstract string `json:"l0_abstract"` // always populated
|
||||
Score float64 `json:"score"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SessionKey string `json:"session_key"`
|
||||
}
|
||||
|
||||
// EpisodicStore manages Tier 2 episodic memory.
|
||||
type EpisodicStore interface {
|
||||
// CRUD
|
||||
Create(ctx context.Context, ep *EpisodicSummary) error
|
||||
Get(ctx context.Context, id string) (*EpisodicSummary, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
List(ctx context.Context, agentID, userID string, limit, offset int) ([]EpisodicSummary, error)
|
||||
|
||||
// Search (hybrid FTS + vector, returns L0 by default)
|
||||
Search(ctx context.Context, query string, agentID, userID string, opts EpisodicSearchOptions) ([]EpisodicSearchResult, error)
|
||||
|
||||
// Lifecycle
|
||||
ExistsBySourceID(ctx context.Context, agentID, userID, sourceID string) (bool, error)
|
||||
PruneExpired(ctx context.Context) (int, error) // delete where expires_at < NOW()
|
||||
|
||||
// Embedding
|
||||
SetEmbeddingProvider(provider EmbeddingProvider)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type EpisodicSearchOptions struct {
|
||||
MaxResults int
|
||||
MinScore float64
|
||||
VectorWeight float64
|
||||
TextWeight float64
|
||||
}
|
||||
|
||||
// MemorySearchResultV3 extends search result with tier indicator.
|
||||
type MemorySearchResultV3 struct {
|
||||
MemorySearchResult // embed existing fields
|
||||
Tier string `json:"tier"` // "working", "episodic", "semantic"
|
||||
EpisodicID string `json:"episodic_id,omitempty"`
|
||||
L0Abstract string `json:"l0_abstract,omitempty"`
|
||||
}
|
||||
|
||||
// UnifiedMemorySearch combines Tier 2 (episodic) and existing memory doc search.
|
||||
// Used by memory_search tool.
|
||||
type UnifiedMemorySearch interface {
|
||||
// Search across all tiers. Returns results with tier indicators.
|
||||
Search(ctx context.Context, query string, agentID, userID string, opts UnifiedSearchOptions) ([]MemorySearchResultV3, error)
|
||||
|
||||
// Expand loads full content for a specific memory entry (L2).
|
||||
Expand(ctx context.Context, id string, tier string) (string, error)
|
||||
}
|
||||
|
||||
type UnifiedSearchOptions struct {
|
||||
MaxResults int
|
||||
MinScore float64
|
||||
Tiers []string // filter: ["episodic", "document"] (empty = all)
|
||||
Depth string // "l0", "l1", "l2" (default "l1")
|
||||
}
|
||||
```
|
||||
|
||||
### KnowledgeGraphStore v3 Extensions
|
||||
|
||||
```go
|
||||
// Temporal query additions to existing KnowledgeGraphStore interface.
|
||||
|
||||
// TemporalQueryOptions extends entity queries with time awareness.
|
||||
type TemporalQueryOptions struct {
|
||||
AsOf *time.Time // point-in-time query (nil = current only)
|
||||
IncludeExpired bool // include superseded facts
|
||||
}
|
||||
|
||||
// New methods added to KnowledgeGraphStore:
|
||||
|
||||
// ListEntitiesTemporal queries entities with temporal awareness.
|
||||
// When opts.AsOf is nil, returns current facts only (valid_until IS NULL).
|
||||
// When opts.AsOf is set, returns facts valid at that timestamp.
|
||||
ListEntitiesTemporal(ctx context.Context, agentID, userID string,
|
||||
listOpts EntityListOptions, temporal TemporalQueryOptions) ([]Entity, error)
|
||||
|
||||
// SupersedeEntity marks entity as no longer valid and inserts replacement.
|
||||
// Atomic: UPDATE old SET valid_until=NOW() + INSERT new in single tx.
|
||||
SupersedeEntity(ctx context.Context, old *Entity, replacement *Entity) error
|
||||
|
||||
// GetDedupConfig returns per-agent dedup thresholds from agents.other_config.
|
||||
// Returns defaults if not configured.
|
||||
GetDedupConfig(ctx context.Context, agentID string) (*KGConfig, error)
|
||||
```
|
||||
|
||||
### Entity Struct v3
|
||||
|
||||
```go
|
||||
// Extended Entity with temporal fields.
|
||||
type Entity struct {
|
||||
// ... existing fields unchanged ...
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Name string `json:"name"`
|
||||
EntityType string `json:"entity_type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Properties map[string]string `json:"properties,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
|
||||
// V3 temporal fields
|
||||
ValidFrom *time.Time `json:"valid_from,omitempty"`
|
||||
ValidUntil *time.Time `json:"valid_until,omitempty"` // nil = currently valid
|
||||
}
|
||||
|
||||
// Relation with temporal fields (same pattern).
|
||||
type Relation struct {
|
||||
// ... existing fields unchanged ...
|
||||
|
||||
// V3 temporal fields
|
||||
ValidFrom *time.Time `json:"valid_from,omitempty"`
|
||||
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Integration Points
|
||||
|
||||
### Where Memory/KG Connects to Pipeline Stages
|
||||
|
||||
| Pipeline stage | Memory/KG interaction |
|
||||
|---------------|----------------------|
|
||||
| **ContextStage** | AutoInjector.Inject() -> produces ## Memory Context section |
|
||||
| **PruneStage** | Triggers MemoryFlushStage before compaction |
|
||||
| **MemoryFlushStage** | Writes to memory docs (existing). Publishes `session.completed` |
|
||||
| **FinalizeStage** | Publishes `session.completed` for episodic consolidation |
|
||||
| **ToolStage** | `memory_search` tool calls UnifiedMemorySearch |
|
||||
| **ToolStage** | `memory_expand` tool calls UnifiedMemorySearch.Expand() |
|
||||
| **ToolStage** | `kg_search` tool uses temporal queries (default: current facts) |
|
||||
|
||||
### Consolidation Pipeline Registration
|
||||
|
||||
```go
|
||||
// At gateway startup, register consolidation workers with DomainEventBus.
|
||||
|
||||
func RegisterConsolidationPipeline(eb eventbus.DomainEventBus, deps ConsolidationDeps) {
|
||||
episodicWorker := NewEpisodicWorker(deps)
|
||||
semanticWorker := NewSemanticExtractionWorker(deps)
|
||||
dedupWorker := NewDedupWorker(deps)
|
||||
|
||||
eb.Subscribe(eventbus.EventSessionCompleted, episodicWorker.Handle)
|
||||
eb.Subscribe(eventbus.EventEpisodicCreated, semanticWorker.Handle)
|
||||
eb.Subscribe(eventbus.EventEntityUpserted, dedupWorker.Handle)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- F1: Episodic summaries created automatically after session completion
|
||||
- F2: L0 abstracts pre-computed at episodic creation (~50 tokens each)
|
||||
- F3: Auto-inject produces relevant L0 summaries for user messages (skip for trivial messages)
|
||||
- F4: Temporal KG queries default to current facts; historical queries via `AsOf` parameter
|
||||
- F5: Contradiction resolution: supersede old fact, insert new with valid_from = NOW()
|
||||
- F6: Dedup thresholds configurable per agent (not hardcoded)
|
||||
- F7: Consolidation pipeline non-blocking (user never waits)
|
||||
- F8: `memory_search` tool returns results with tier indicators
|
||||
- F9: `memory_expand` tool loads L2 full content
|
||||
- F10: Expired episodic entries pruned automatically (cron or lazy)
|
||||
|
||||
### Non-Functional
|
||||
- NF1: Auto-inject latency < 50ms p99 (BM25 + embedding lookup)
|
||||
- NF2: Episodic summarization completes within 30s (LLM call)
|
||||
- NF3: Consolidation pipeline handles 100 session.completed events/minute
|
||||
- NF4: pgvector index efficient up to 100K embeddings per agent
|
||||
- NF5: L0 injection budget: max 200 tokens per turn
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/store/knowledge_graph_store.go` | Add temporal fields to Entity/Relation, new temporal query methods |
|
||||
| `internal/store/pg/knowledge_graph*.go` | Implement temporal queries, supersede logic |
|
||||
| `internal/store/memory_store.go` | No change (existing store retained for Tier 1/document memory) |
|
||||
| `internal/knowledgegraph/extractor.go` | Source extraction from episodic summaries (lighter than raw messages) |
|
||||
| `internal/agent/systemprompt.go` | Add ## Memory Context section from AutoInjector |
|
||||
| `internal/tools/memory_tools.go` | Update memory_search to use UnifiedMemorySearch, add memory_expand |
|
||||
|
||||
### New files to create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/store/episodic_store.go` | EpisodicStore interface + types |
|
||||
| `internal/store/pg/episodic_summaries.go` | PG implementation |
|
||||
| `internal/memory/auto_injector.go` | AutoInjector implementation |
|
||||
| `internal/memory/l1_cache.go` | L1 on-demand generation + LRU cache |
|
||||
| `internal/consolidation/episodic_worker.go` | session.completed -> episodic summary |
|
||||
| `internal/consolidation/semantic_worker.go` | episodic.created -> KG extraction |
|
||||
| `internal/consolidation/dedup_worker.go` | entity.upserted -> dedup check |
|
||||
| `internal/consolidation/registration.go` | Worker registration with EventBus |
|
||||
| `migrations/000XXX_episodic_summaries.up.sql` | Episodic table + indexes |
|
||||
| `migrations/000XXX_kg_temporal.up.sql` | Temporal fields on KG tables |
|
||||
|
||||
## Design Steps
|
||||
|
||||
1. Define `episodic_summaries` DDL with indexes
|
||||
2. Define `EpisodicStore` interface
|
||||
3. Design temporal KG schema changes (ALTER TABLE + backfill)
|
||||
4. Define temporal query methods for KnowledgeGraphStore
|
||||
5. Design consolidation pipeline event flow
|
||||
6. Design EpisodicWorker with summarization prompt + L0 generation
|
||||
7. Design AutoInjector with BM25 + embedding relevance check
|
||||
8. Define UnifiedMemorySearch interface for cross-tier search
|
||||
9. Design L0/L1/L2 depth parameter for search results
|
||||
10. Define per-agent memory config extensions
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] A1: episodic_summaries DDL — documented in phase (SQL in Phase 6)
|
||||
- [x] A2: EpisodicStore interface — `internal/store/episodic_store.go`
|
||||
- [x] A3: EpisodicSummary struct + EpisodicSearchResult — `internal/store/episodic_store.go`
|
||||
- [x] B1: kg_entities temporal fields DDL — documented in phase (SQL in Phase 6)
|
||||
- [x] B2: kg_relations temporal fields DDL — documented in phase (SQL in Phase 6)
|
||||
- [x] B3: Backfill migration — documented in phase design
|
||||
- [x] B4: Temporal query methods — `internal/store/knowledge_graph_temporal.go`
|
||||
- [x] B5: SupersedeEntity atomic method — `internal/store/knowledge_graph_temporal.go`
|
||||
- [x] B6: Configurable dedup thresholds — `internal/store/knowledge_graph_temporal.go` KGConfig
|
||||
- [x] C1: Consolidation event flow design — documented in phase
|
||||
- [x] C2: EpisodicWorker handler — `internal/consolidation/workers.go` (stub)
|
||||
- [x] C3: SemanticExtractionWorker handler — `internal/consolidation/workers.go` (stub)
|
||||
- [x] C4: DedupWorker handler — `internal/consolidation/workers.go` (stub)
|
||||
- [x] C5: Worker registration at startup — `internal/consolidation/workers.go` Register()
|
||||
- [x] D1: L0 abstract generation — documented in phase design (extractive v3.0)
|
||||
- [x] D2: L1 on-demand generation + LRU cache — documented in phase design
|
||||
- [x] D3: L2 access via memory_expand tool — documented in phase design
|
||||
- [x] E1: AutoInjector interface — `internal/memory/auto_injector.go`
|
||||
- [x] E2: Relevance check (BM25 + embedding) — documented in phase design
|
||||
- [x] E3: System prompt ## Memory Context section — documented in phase design
|
||||
- [x] E4: Per-agent threshold config — `internal/memory/auto_injector.go` MemoryConfig
|
||||
- [x] F1: UnifiedMemorySearch interface — documented in phase design
|
||||
- [x] F2: MemorySearchResultV3 with tier indicator — documented in phase design
|
||||
- [x] F3: memory_search tool update — documented in phase design
|
||||
- [x] F4: memory_expand tool definition — documented in phase design
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- episodic_summaries DDL passes `psql` validation
|
||||
- Temporal KG migration backward-compatible (existing data has valid_from=created_at, valid_until=NULL)
|
||||
- All consolidation workers idempotent (safe to replay events)
|
||||
- AutoInjector returns "" for trivial messages ("hello", "ok")
|
||||
- AutoInjector returns relevant L0s for topic-specific messages
|
||||
- UnifiedMemorySearch merges results across tiers with correct tier labels
|
||||
- Per-agent dedup thresholds configurable, defaults match current hardcoded values
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Episodic summarization LLM cost | MEDIUM | Use smaller/cheaper model for summarization (e.g. GPT-4o-mini). Configurable per agent. |
|
||||
| Auto-inject false positives (irrelevant memories injected) | MEDIUM | Conservative default threshold (0.3). Agent can override. Monitor via evolution metrics. |
|
||||
| pgvector index performance at scale (100K+ embeddings) | MEDIUM | ivfflat index with lists=50. Benchmark needed. Increase lists for larger datasets. |
|
||||
| Consolidation pipeline backlog during high load | LOW | Lane-based concurrency in EventBus. Workers are idempotent. Backlog clears naturally. |
|
||||
| L0 extractive quality insufficient | LOW | Extractive is "good enough" for v3.0. LLM-generated L0 in v3.1. |
|
||||
| Temporal backfill migration slow on large KG | LOW | Backfill is simple UPDATE SET DEFAULT. Add timeout guard. |
|
||||
| Cross-agent memory leakage | HIGH | All queries scoped by tenant_id + agent_id + user_id. Verify in code review. |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Tenant isolation: ALL episodic + KG queries must include tenant_id scope. Missing tenant_id = data leak.
|
||||
- User isolation: episodic summaries scoped by user_id unless shared memory enabled
|
||||
- L0/L1 content is derived from user conversations -- same access controls as original messages
|
||||
- Consolidation workers run in system context -- must not expose cross-tenant data
|
||||
- AutoInjector results filtered by same agent_id + user_id scope as memory_search
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. **Episodic TTL**: 90 days default. Should this be configurable per agent? (Likely yes, via MemoryConfig)
|
||||
2. **L0 for memory documents**: Should existing memory_documents also get L0 abstracts? (Deferred to v3.1 -- episodic first)
|
||||
3. **Cross-agent memory**: Should agents in same tenant share semantic memory? (Brainstorm Q2, deferred)
|
||||
4. **Embedding model**: Continue OpenAI text-embedding-3-small or switch to open model? (Brainstorm Q3, deferred)
|
||||
5. **Concurrent consolidation**: Multiple sessions ending simultaneously -> same episodic worker. EventBus dedup by source_id handles this, but verify ordering.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 4 (System Integration) integrates AutoInjector into ContextStage template
|
||||
- Phase 5 (Orchestration) uses self-evolution metrics from consolidation pipeline
|
||||
- Phase 6 (Migration) handles v2 daily memory files -> episodic import
|
||||
@@ -1,707 +0,0 @@
|
||||
# Phase 4: System Integration Design
|
||||
|
||||
## Context Links
|
||||
- Brainstorm: `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md` (sections 9, 12, 14, 7)
|
||||
- Architecture report: `plans/reports/Explore-260406-thorough-v3-architecture.md` (sections 5, 6)
|
||||
- Workspace forcing: `plans/reports/Explore-260406-workspace-forcing.md`
|
||||
- Current system prompt: `internal/agent/systemprompt.go`, `internal/agent/systemprompt_sections.go`
|
||||
- Current tool registry: `internal/tools/registry.go`, `internal/tools/policy.go`, `internal/tools/types.go`
|
||||
- Current skills: `internal/skills/loader.go`, `internal/skills/search.go`, `internal/store/skill_store.go`
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1 (core dependency for pipeline loop + memory system)
|
||||
- **Status:** Complete
|
||||
- **Effort:** 6h design
|
||||
|
||||
Redesigns 4 tightly-coupled systems that sit between the agent loop and the LLM:
|
||||
1. Template-based system prompt (replace string concatenation)
|
||||
2. Tool registry with typed capabilities + declarative policy
|
||||
3. Skills system with embedding search + ACL + dependency graph
|
||||
4. Retrieval integration connecting auto-inject L0 with template + tools
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. Current `BuildSystemPrompt()` is ~200 lines of conditional string building. 15+ sections toggled by booleans. Adding workspace enforcement language or memory context requires touching fragile concatenation logic.
|
||||
2. Tool types use 12+ optional interfaces (`InterceptorAware`, `PathAllowable`, etc.) with type assertions at registration. No capability metadata on tools themselves.
|
||||
3. PolicyEngine's 7-step pipeline works but all checks are string-based group matching. No typed capabilities (read-only vs mutating), no workspace-awareness.
|
||||
4. Skills BM25-only search misses intent-based queries ("help me debug" won't find "debugger" skill). `EmbeddingSkillSearcher` interface exists in store but not wired to loader's `BuildSummary()`.
|
||||
5. Workspace confusion root cause: system prompt text says "use relative paths" but doesn't state the enforcement rule. Template must carry WorkspaceContext enforcement language verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- F1: System prompt built from Go `text/template` with embedded template file
|
||||
- F2: Each template section independently toggleable via PromptConfig
|
||||
- F3: WorkspaceContext generates explicit enforcement language (not suggestive)
|
||||
- F4: Tools declare capabilities (read-only, mutating, async, mcp-bridged) at registration
|
||||
- F5: Policy engine evaluates capabilities (e.g., "deny all mutating tools for viewer role")
|
||||
- F6: Tools receive WorkspaceContext, not raw path strings
|
||||
- F7: Skills searched by both BM25 (lexical) and embedding (semantic), results merged
|
||||
- F8: Skills filtered by agent/tenant ACL before injection
|
||||
- F9: Skill dependency graph: loader resolves + validates before activation
|
||||
- F10: Auto-inject L0 memory summaries into template's Memory section
|
||||
- F11: Provider-specific template variants (e.g., Codex needs different tool format)
|
||||
|
||||
### Non-Functional
|
||||
- NF1: Template rendering <1ms (no LLM calls during prompt build)
|
||||
- NF2: Skill embedding search <50ms at 1000 skills
|
||||
- NF3: Backward compatible: v2 PromptConfig can produce identical v2 output
|
||||
- NF4: Template testable in isolation (no DB dependency for rendering)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### A. Template-Based System Prompt
|
||||
|
||||
#### PromptConfig Interface Contract
|
||||
|
||||
```go
|
||||
// PromptConfig controls which template sections to include.
|
||||
// Each field maps to a named template block.
|
||||
type PromptConfig struct {
|
||||
// Section toggles
|
||||
Identity bool
|
||||
Persona bool // SOUL.md content
|
||||
Instructions bool // AGENTS.md content
|
||||
Tools bool
|
||||
Skills bool
|
||||
Team bool
|
||||
Workspace bool
|
||||
Memory bool // auto-inject L0 section
|
||||
Sandbox bool
|
||||
Recency bool // recency reminder
|
||||
|
||||
// Data payloads (populated when section enabled)
|
||||
IdentityData IdentityData
|
||||
PersonaContent string // raw SOUL.md
|
||||
InstructionContent string // raw AGENTS.md
|
||||
ToolsData ToolsSectionData
|
||||
SkillsData SkillsSectionData
|
||||
TeamData TeamSectionData
|
||||
WorkspaceData WorkspaceSectionData
|
||||
MemoryData MemorySectionData
|
||||
SandboxData SandboxSectionData
|
||||
ExtraPrompt string
|
||||
|
||||
// Provider variant (selects template file)
|
||||
ProviderVariant string // "" = default, "codex", "dashscope"
|
||||
}
|
||||
```
|
||||
|
||||
#### Section Data Types
|
||||
|
||||
```go
|
||||
type IdentityData struct {
|
||||
AgentName string
|
||||
Emoji string
|
||||
Model string
|
||||
Channel string
|
||||
ChatTitle string
|
||||
PeerKind string // "direct" or "group"
|
||||
}
|
||||
|
||||
type ToolsSectionData struct {
|
||||
ToolDefs []ToolSummary // name + one-line description
|
||||
MCPTools []ToolSummary // MCP bridge tools (separate listing)
|
||||
CoreSummary map[string]string // builtin tool summaries
|
||||
}
|
||||
|
||||
type ToolSummary struct {
|
||||
Name string
|
||||
Description string
|
||||
Capability ToolCapability // read-only, mutating, async, mcp-bridged
|
||||
}
|
||||
|
||||
type SkillsSectionData struct {
|
||||
Mode string // "inline" (inject summaries) or "search" (search tool only)
|
||||
Summaries []SkillSummaryEntry
|
||||
}
|
||||
|
||||
type SkillSummaryEntry struct {
|
||||
Slug string
|
||||
Name string
|
||||
Description string
|
||||
Score float64 // relevance score from search
|
||||
}
|
||||
|
||||
type TeamSectionData struct {
|
||||
TeamWorkspace string
|
||||
Members []TeamMemberEntry
|
||||
Guidance string // edition-specific (Full vs Lite)
|
||||
TeamMDContent string // TEAM.md content
|
||||
}
|
||||
|
||||
type TeamMemberEntry struct {
|
||||
AgentKey string
|
||||
DisplayName string
|
||||
Skills string
|
||||
Role string // "lead" or "member"
|
||||
}
|
||||
|
||||
type WorkspaceSectionData struct {
|
||||
ActivePath string
|
||||
Scope string // "personal" | "team-shared" | "team-isolated" | "delegate"
|
||||
Enforced bool // always true in v3
|
||||
ReadOnlyPaths []string
|
||||
SharedPath *string
|
||||
ContextFiles []string // list of managed context files
|
||||
EnforcementMsg string // pre-rendered enforcement text
|
||||
}
|
||||
|
||||
type MemorySectionData struct {
|
||||
L0Summaries []L0Summary // auto-injected memory hints
|
||||
HasSearch bool // memory_search tool available
|
||||
HasKG bool // kg_search tool available
|
||||
}
|
||||
|
||||
type L0Summary struct {
|
||||
Topic string
|
||||
Summary string // 1-sentence abstract
|
||||
ID string // for memory_expand(id) deep retrieval
|
||||
}
|
||||
|
||||
type SandboxSectionData struct {
|
||||
ContainerDir string
|
||||
AccessLevel string // "full" or "read-only"
|
||||
}
|
||||
```
|
||||
|
||||
#### Template Structure
|
||||
|
||||
Embedded template file at `internal/agent/prompt_template.go.tmpl`:
|
||||
|
||||
```
|
||||
{{- /* System Prompt Template — v3 */ -}}
|
||||
|
||||
{{- if .Identity -}}
|
||||
{{- template "identity" .IdentityData -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Persona -}}
|
||||
# Persona
|
||||
{{ .PersonaContent }}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Instructions -}}
|
||||
# Instructions
|
||||
{{ .InstructionContent }}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Workspace -}}
|
||||
{{- template "workspace" .WorkspaceData -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Tools -}}
|
||||
{{- template "tools" .ToolsData -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Skills -}}
|
||||
{{- template "skills" .SkillsData -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Memory -}}
|
||||
{{- template "memory" .MemoryData -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Sandbox -}}
|
||||
{{- template "sandbox" .SandboxData -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .Team -}}
|
||||
{{- template "team" .TeamData -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if .ExtraPrompt -}}
|
||||
# Additional Context
|
||||
{{ .ExtraPrompt }}
|
||||
{{- end -}}
|
||||
```
|
||||
|
||||
#### Workspace Sub-Template (Critical)
|
||||
|
||||
```
|
||||
{{- define "workspace" -}}
|
||||
# Workspace
|
||||
- **Path:** {{ .ActivePath }}
|
||||
- **Scope:** {{ .Scope }}
|
||||
- **ENFORCED:** All file tool paths resolve relative to this directory. Absolute paths outside workspace are REJECTED.
|
||||
{{- if .ReadOnlyPaths }}
|
||||
- **Read-only access:** {{ range .ReadOnlyPaths }}{{ . }}, {{ end }}
|
||||
{{- end }}
|
||||
{{- if .SharedPath }}
|
||||
- **Shared area:** {{ .SharedPath }} (read/write by both you and delegator)
|
||||
{{- end }}
|
||||
{{- if .ContextFiles }}
|
||||
- **Managed files:** {{ range .ContextFiles }}{{ . }}, {{ end }}(system-managed, read/write intercepted to DB)
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
```
|
||||
|
||||
#### Memory Sub-Template
|
||||
|
||||
```
|
||||
{{- define "memory" -}}
|
||||
# Memory Context
|
||||
{{- if .L0Summaries }}
|
||||
{{- range .L0Summaries }}
|
||||
- **{{ .Topic }}:** {{ .Summary }} [use memory_search("{{ .ID }}") for details]
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .HasSearch }}
|
||||
Use `memory_search` for detailed retrieval. Use `kg_search` for relationship queries.
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
```
|
||||
|
||||
#### PromptBuilder Interface
|
||||
|
||||
```go
|
||||
// PromptBuilder renders system prompts from PromptConfig.
|
||||
type PromptBuilder interface {
|
||||
Build(cfg PromptConfig) (string, error)
|
||||
}
|
||||
|
||||
// TemplatePromptBuilder implements PromptBuilder using text/template.
|
||||
type TemplatePromptBuilder struct {
|
||||
templates map[string]*template.Template // variant -> parsed template
|
||||
}
|
||||
|
||||
// NewTemplatePromptBuilder loads embedded templates.
|
||||
func NewTemplatePromptBuilder() (*TemplatePromptBuilder, error)
|
||||
|
||||
// Build renders prompt. Selects template variant from cfg.ProviderVariant.
|
||||
// Returns rendered string. Error only on template parse failure (should not happen with embedded).
|
||||
func (b *TemplatePromptBuilder) Build(cfg PromptConfig) (string, error)
|
||||
```
|
||||
|
||||
#### Provider Variants
|
||||
|
||||
- **Default template:** Standard Anthropic/OpenAI format
|
||||
- **Codex variant:** Tool descriptions formatted as docstrings (Codex prefers this)
|
||||
- **DashScope variant:** No streaming+tools section (DashScope limitation)
|
||||
- Variant selected by `cfg.ProviderVariant` which maps to provider capabilities
|
||||
|
||||
---
|
||||
|
||||
### B. Tool Registry Redesign
|
||||
|
||||
#### Tool Capability Types
|
||||
|
||||
```go
|
||||
// ToolCapability describes what a tool can do.
|
||||
type ToolCapability string
|
||||
|
||||
const (
|
||||
CapReadOnly ToolCapability = "read-only" // no side effects (read_file, list_files, memory_search)
|
||||
CapMutating ToolCapability = "mutating" // modifies state (write_file, exec, edit)
|
||||
CapAsync ToolCapability = "async" // returns immediately, result via callback
|
||||
CapMCPBridged ToolCapability = "mcp-bridged" // proxied to external MCP server
|
||||
)
|
||||
|
||||
// ToolMetadata describes a tool's capabilities and requirements.
|
||||
type ToolMetadata struct {
|
||||
Name string
|
||||
Capabilities []ToolCapability
|
||||
Group string // "fs", "web", "runtime", "memory", "team", etc.
|
||||
RequiresWorkspace bool // tool needs WorkspaceContext
|
||||
ProviderHints map[string]any // provider-specific hints (e.g., cache_control)
|
||||
}
|
||||
```
|
||||
|
||||
#### Enhanced Tool Interface
|
||||
|
||||
```go
|
||||
// Tool is the base interface all tools must implement.
|
||||
// V3 adds Metadata() for capability declaration.
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() map[string]any
|
||||
Execute(ctx context.Context, args map[string]any) *Result
|
||||
Metadata() ToolMetadata // NEW: declarative capability info
|
||||
}
|
||||
```
|
||||
|
||||
> **Migration note:** Existing tools that don't implement `Metadata()` get a wrapper
|
||||
> that returns default metadata (capabilities inferred from tool group membership).
|
||||
|
||||
#### WorkspaceContext in Tool Context
|
||||
|
||||
```go
|
||||
// Tools access WorkspaceContext via context, not struct fields.
|
||||
// Replaces: ToolWorkspaceFromCtx(), ToolTeamWorkspaceFromCtx()
|
||||
|
||||
func WorkspaceContextFromCtx(ctx context.Context) *WorkspaceContext
|
||||
func WithWorkspaceContext(ctx context.Context, wc *WorkspaceContext) context.Context
|
||||
```
|
||||
|
||||
All file tools use `WorkspaceContextFromCtx(ctx)` instead of separate workspace/team-workspace context keys. WorkspaceContext carries ActivePath, ReadOnlyPaths, SharedPath — one struct, one lookup.
|
||||
|
||||
#### Enhanced PolicyEngine
|
||||
|
||||
```go
|
||||
// PolicyRule is a declarative access rule.
|
||||
type PolicyRule struct {
|
||||
// Match conditions (all must match)
|
||||
Capabilities []ToolCapability // match tools with these capabilities
|
||||
Groups []string // match tools in these groups
|
||||
Names []string // match specific tool names
|
||||
|
||||
// Action
|
||||
Action PolicyAction // allow or deny
|
||||
Reason string // audit log reason
|
||||
}
|
||||
|
||||
type PolicyAction string
|
||||
const (
|
||||
PolicyAllow PolicyAction = "allow"
|
||||
PolicyDeny PolicyAction = "deny"
|
||||
)
|
||||
|
||||
// PolicyEngine evaluates tool access via ordered rule list.
|
||||
// V3 adds capability-based rules alongside existing group/name rules.
|
||||
type PolicyEngine struct {
|
||||
globalPolicy *config.ToolsConfig
|
||||
rules []PolicyRule // declarative rules (evaluated in order, first match wins)
|
||||
}
|
||||
|
||||
// FilterTools returns allowed tools. Now evaluates both legacy 7-step pipeline
|
||||
// AND declarative rules. Declarative rules take precedence.
|
||||
func (pe *PolicyEngine) FilterTools(
|
||||
registry ToolExecutor,
|
||||
agentID string,
|
||||
providerName string,
|
||||
agentToolPolicy *config.ToolPolicySpec,
|
||||
groupToolAllow []string,
|
||||
isSubagent bool,
|
||||
isLeafAgent bool,
|
||||
role string, // NEW: RBAC role ("admin", "operator", "viewer")
|
||||
) []providers.ToolDefinition
|
||||
```
|
||||
|
||||
#### Tool Definition Generation for Providers
|
||||
|
||||
```go
|
||||
// ToolDefAdapter generates provider-specific tool definitions.
|
||||
// Replaces direct ToProviderDef() calls.
|
||||
type ToolDefAdapter interface {
|
||||
// ToProviderDefs converts internal tool list to provider wire format.
|
||||
// Handles provider-specific quirks (DashScope tool format, Codex docstrings, etc.)
|
||||
ToProviderDefs(tools []Tool, caps ProviderCapabilities) []providers.ToolDefinition
|
||||
}
|
||||
```
|
||||
|
||||
#### Pipeline Integration
|
||||
|
||||
ToolStage in pipeline receives registry + policy engine:
|
||||
```go
|
||||
type ToolStage struct {
|
||||
registry *Registry
|
||||
policyEngine *PolicyEngine
|
||||
adapter ToolDefAdapter
|
||||
}
|
||||
|
||||
// Execute runs tool, records metrics, returns result for ObserveStage.
|
||||
func (s *ToolStage) Execute(ctx context.Context, state *RunState) error
|
||||
```
|
||||
|
||||
ObserveStage processes tool results:
|
||||
```go
|
||||
type ObserveStage struct {
|
||||
loopDetector *LoopDetector
|
||||
metricsStore EvolutionMetricsStore // record tool effectiveness
|
||||
}
|
||||
|
||||
// Execute checks for tool loops, records metrics, decides continue/stop.
|
||||
func (s *ObserveStage) Execute(ctx context.Context, state *RunState) error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. Skills System Upgrade
|
||||
|
||||
#### Hybrid Search Interface
|
||||
|
||||
```go
|
||||
// SkillSearcher provides both lexical and semantic skill search.
|
||||
type SkillSearcher interface {
|
||||
// Search returns skills matching query, merging BM25 + embedding results.
|
||||
// Returns at most limit results, deduplicated by slug.
|
||||
Search(ctx context.Context, query string, limit int) ([]SkillSearchResult, error)
|
||||
|
||||
// SearchForAgent filters by ACL before search.
|
||||
SearchForAgent(ctx context.Context, agentID uuid.UUID, userID string, query string, limit int) ([]SkillSearchResult, error)
|
||||
}
|
||||
|
||||
// HybridSkillSearcher implements SkillSearcher with BM25 + embedding fusion.
|
||||
type HybridSkillSearcher struct {
|
||||
bm25Index *skills.Index // existing BM25 index
|
||||
embSearcher store.EmbeddingSkillSearcher // existing PG embedding search
|
||||
accessStore store.SkillAccessStore // ACL filtering
|
||||
bm25Weight float64 // default 0.4
|
||||
embedWeight float64 // default 0.6
|
||||
}
|
||||
```
|
||||
|
||||
#### Score Fusion Strategy
|
||||
|
||||
```
|
||||
1. BM25 search → top 2*limit results, normalize scores to [0,1]
|
||||
2. Embedding search → top 2*limit results, normalize scores to [0,1]
|
||||
3. Merge by slug: combined_score = bm25Weight * bm25_score + embedWeight * embed_score
|
||||
4. Sort by combined_score descending
|
||||
5. Return top limit
|
||||
```
|
||||
|
||||
#### ACL Model
|
||||
|
||||
```go
|
||||
// SkillVisibility controls who can see/use a skill.
|
||||
type SkillVisibility string
|
||||
|
||||
const (
|
||||
SkillVisibilityPublic SkillVisibility = "public" // all agents in tenant
|
||||
SkillVisibilityPrivate SkillVisibility = "private" // only granted agents
|
||||
SkillVisibilitySystem SkillVisibility = "system" // system-managed, immutable
|
||||
)
|
||||
|
||||
// ACL check flow:
|
||||
// 1. If skill.Visibility == "public" → allow
|
||||
// 2. If skill.Visibility == "private" → check skill_agent_grants table
|
||||
// 3. If skill.Visibility == "system" → allow (system skills always accessible)
|
||||
// Existing SkillAccessStore.ListAccessible() already implements this.
|
||||
```
|
||||
|
||||
No new DB tables needed. Existing `managed_skills.visibility` + `skill_agent_grants` + `SkillAccessStore` interface covers ACL. The change is wiring ACL into the search path (currently BM25 search ignores ACL).
|
||||
|
||||
#### Dependency Graph
|
||||
|
||||
```go
|
||||
// SkillDependency represents skill A requires skill B.
|
||||
// Stored in skill frontmatter: `depends: [slug-b, slug-c]`
|
||||
type SkillDependency struct {
|
||||
Slug string // this skill
|
||||
Requires []string // slugs of required skills
|
||||
}
|
||||
|
||||
// DepChecker validates skill dependencies are satisfied.
|
||||
// Existing internal/skills/dep_checker.go already handles this.
|
||||
// V3 enhancement: block activation of skill with unresolved deps.
|
||||
type DepChecker interface {
|
||||
// Check returns missing dependencies for a skill.
|
||||
Check(slug string, available []string) []string
|
||||
|
||||
// ResolveOrder returns activation order respecting dependency graph.
|
||||
// Error if circular dependency detected.
|
||||
ResolveOrder(slugs []string) ([]string, error)
|
||||
}
|
||||
```
|
||||
|
||||
No new DB schema. Dependencies stored in SKILL.md frontmatter `depends:` field. `dep_checker.go` already parses this. V3 adds `ResolveOrder()` for topological sort + circular detection.
|
||||
|
||||
#### Hot-Reload Enhancement
|
||||
|
||||
```go
|
||||
// SkillLoader manages skill discovery, loading, and hot-reload.
|
||||
// V3 adds ACL-aware loading + hybrid search integration.
|
||||
type SkillLoader interface {
|
||||
// LoadAll discovers and loads all skills from all tiers.
|
||||
LoadAll(ctx context.Context) error
|
||||
|
||||
// BuildSummary returns skill summary for system prompt injection.
|
||||
// V3: uses HybridSkillSearcher instead of BM25-only.
|
||||
BuildSummary(ctx context.Context, agentID uuid.UUID, userID string, query string) string
|
||||
|
||||
// Version returns current reload version (atomic counter).
|
||||
Version() int64
|
||||
|
||||
// OnReload registers callback for hot-reload events.
|
||||
OnReload(fn func())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### D. Retrieval Integration
|
||||
|
||||
#### Auto-Inject L0 Flow
|
||||
|
||||
```
|
||||
Per turn (in ContextStage):
|
||||
1. Extract keywords from latest user message (simple tokenization, same as BM25)
|
||||
2. Check relevance against memory index:
|
||||
a. BM25 score against episodic summaries
|
||||
b. Embedding similarity against semantic memory (KG entities)
|
||||
3. If max_score > agent.retrieval_threshold (default 0.3):
|
||||
→ Build L0Summaries for PromptConfig.MemoryData
|
||||
→ Cap at max_l0_tokens (default 200 tokens, counted via TokenCounter)
|
||||
4. PromptBuilder renders Memory section with L0 hints
|
||||
5. Agent sees hints, can call memory_search(id) for L1/L2 detail
|
||||
```
|
||||
|
||||
#### Retrieval Config
|
||||
|
||||
```go
|
||||
// RetrievalConfig controls auto-inject behavior. Per-agent configurable.
|
||||
type RetrievalConfig struct {
|
||||
Enabled bool `json:"enabled"` // default true
|
||||
RelevanceThreshold float64 `json:"relevance_threshold"` // default 0.3
|
||||
MaxL0Tokens int `json:"max_l0_tokens"` // default 200
|
||||
MaxL0Items int `json:"max_l0_items"` // default 5
|
||||
BM25Weight float64 `json:"bm25_weight"` // default 0.4
|
||||
EmbeddingWeight float64 `json:"embedding_weight"` // default 0.6
|
||||
}
|
||||
```
|
||||
|
||||
Stored in agent settings JSON (`agents.settings` column, existing JSONB field). No new DB columns.
|
||||
|
||||
#### Retrieval Interface
|
||||
|
||||
```go
|
||||
// Retriever performs auto-inject L0 retrieval for ContextStage.
|
||||
type Retriever interface {
|
||||
// RetrieveL0 returns L0 summaries relevant to the query.
|
||||
// Called once per turn in ContextStage.
|
||||
RetrieveL0(ctx context.Context, agentID uuid.UUID, userID string, query string, cfg RetrievalConfig) ([]L0Summary, error)
|
||||
}
|
||||
|
||||
// HybridRetriever implements Retriever using memory + KG stores.
|
||||
type HybridRetriever struct {
|
||||
memoryStore store.MemoryStore
|
||||
kgStore store.KnowledgeGraphStore
|
||||
tokenCounter TokenCounter
|
||||
embProvider store.EmbeddingProvider
|
||||
}
|
||||
```
|
||||
|
||||
#### memory_search / kg_search Tool Updates
|
||||
|
||||
```go
|
||||
// memory_search tool gains L1/L2 mode parameter.
|
||||
// Args: { "query": "...", "depth": "l1"|"l2" }
|
||||
// L1: returns structured overview (episodic summaries, ~200 tokens each)
|
||||
// L2: returns full detail (original content, KG traversal)
|
||||
// Default: "l1" (backward compatible — current behavior is L1-equivalent)
|
||||
|
||||
// kg_search tool gains temporal parameter.
|
||||
// Args: { "query": "...", "as_of": "2026-01-01" }
|
||||
// as_of: returns facts valid at that date. Omit = current facts only.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to Modify
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/agent/systemprompt.go` | Replace BuildSystemPrompt() with PromptBuilder.Build() |
|
||||
| `internal/agent/systemprompt_sections.go` | Extract section data builders, remove string concat |
|
||||
| `internal/agent/loop_history.go` | Use PromptBuilder in buildMessages() |
|
||||
| `internal/tools/types.go` | Add Metadata() to Tool interface |
|
||||
| `internal/tools/registry.go` | Store ToolMetadata, add capability queries |
|
||||
| `internal/tools/policy.go` | Add capability-based rules, role parameter |
|
||||
| `internal/tools/context_keys.go` | Replace dual workspace keys with WorkspaceContext |
|
||||
| `internal/tools/filesystem.go` | Use WorkspaceContextFromCtx() |
|
||||
| `internal/tools/filesystem_write.go` | Use WorkspaceContextFromCtx() |
|
||||
| `internal/tools/filesystem_list.go` | Use WorkspaceContextFromCtx() |
|
||||
| `internal/tools/shell.go` | Use WorkspaceContextFromCtx() |
|
||||
| `internal/skills/loader.go` | Wire HybridSkillSearcher into BuildSummary() |
|
||||
| `internal/skills/search.go` | Keep BM25, add fusion logic |
|
||||
| `internal/tools/memory_search.go` | Add depth parameter (l1/l2) |
|
||||
| `internal/tools/knowledge_graph_search.go` | Add as_of temporal parameter |
|
||||
|
||||
### Files to Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/agent/prompt_builder.go` | TemplatePromptBuilder implementation |
|
||||
| `internal/agent/prompt_template.go.tmpl` | Embedded template file (default variant) |
|
||||
| `internal/agent/prompt_template_codex.go.tmpl` | Codex variant |
|
||||
| `internal/agent/prompt_config_types.go` | PromptConfig, section data types |
|
||||
| `internal/tools/capability.go` | ToolCapability, ToolMetadata types |
|
||||
| `internal/tools/workspace_context.go` | WorkspaceContext struct + context helpers |
|
||||
| `internal/tools/tool_def_adapter.go` | ToolDefAdapter interface + default impl |
|
||||
| `internal/skills/hybrid_searcher.go` | HybridSkillSearcher |
|
||||
| `internal/agent/retriever.go` | Retriever interface + HybridRetriever |
|
||||
|
||||
---
|
||||
|
||||
## Design Steps
|
||||
|
||||
1. Define PromptConfig + all section data types in `prompt_config_types.go`
|
||||
2. Write default template file with all section sub-templates
|
||||
3. Implement TemplatePromptBuilder with variant loading
|
||||
4. Define ToolCapability + ToolMetadata types
|
||||
5. Add Metadata() to Tool interface with backward-compat wrapper
|
||||
6. Define WorkspaceContext struct with context helpers
|
||||
7. Update PolicyEngine: add capability rules + role parameter
|
||||
8. Define ToolDefAdapter for provider-specific tool def generation
|
||||
9. Implement HybridSkillSearcher with score fusion
|
||||
10. Wire ACL into skill search path
|
||||
11. Define Retriever interface + RetrievalConfig
|
||||
12. Implement HybridRetriever for L0 auto-inject
|
||||
13. Update memory_search tool with depth parameter design
|
||||
14. Update kg_search tool with as_of temporal parameter design
|
||||
15. Write provider variant templates (Codex, DashScope)
|
||||
|
||||
---
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] A1: PromptConfig + section data types — `internal/agent/prompt_config_types.go`
|
||||
- [x] A2: Default template file — documented in phase design
|
||||
- [x] A3: TemplatePromptBuilder interface — `internal/agent/prompt_config_types.go`
|
||||
- [x] A4: Provider variant templates — documented in phase design
|
||||
- [x] A5: Workspace sub-template — documented in phase design
|
||||
- [x] B1: ToolCapability + ToolMetadata — `internal/tools/capability.go`
|
||||
- [x] B2: Backward-compat wrapper — documented in phase design
|
||||
- [x] B3: WorkspaceContext + context key — done in Phase 1 (`internal/workspace/`)
|
||||
- [x] B4: PolicyEngine capability rules — documented in phase design
|
||||
- [x] B5: ToolDefAdapter interface — documented in phase design
|
||||
- [x] C1: HybridSkillSearcher — documented in phase design
|
||||
- [x] C2: ACL wiring into search path — documented in phase design
|
||||
- [x] C3: DepChecker.ResolveOrder() — documented in phase design
|
||||
- [x] D1: Retriever + RetrievalConfig — `internal/agent/retriever.go`
|
||||
- [x] D2: L0 auto-inject flow — documented in phase design
|
||||
- [x] D3: memory_search depth param — documented in phase design
|
||||
- [x] D4: kg_search as_of temporal param — documented in phase design
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. PromptConfig can reproduce exact v2 system prompt output (regression test)
|
||||
2. All template sections independently toggleable + testable
|
||||
3. WorkspaceContext carries all info needed by file tools (no separate context keys)
|
||||
4. Every tool declares capabilities via Metadata()
|
||||
5. PolicyEngine can express "deny mutating tools for viewer role" in one rule
|
||||
6. HybridSkillSearcher returns better results than BM25-only on intent queries
|
||||
7. L0 auto-inject adds <200 tokens per turn, only when relevant
|
||||
8. Template renders in <1ms (no LLM calls)
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Template engine too rigid for edge cases | MEDIUM | Keep ExtraPrompt escape hatch. Provider variants handle outliers. |
|
||||
| Metadata() breaks all existing tool implementations | HIGH | Backward-compat wrapper: `defaultMetadata(tool)` infers from group/name. Phase implementation. |
|
||||
| Hybrid skill search slower than BM25-only | LOW | BM25 <1ms, embedding <50ms. Parallel execution. Cache embeddings. |
|
||||
| WorkspaceContext migration touches every file tool | HIGH | Single context key replaces 2. Grep for `ToolWorkspaceFromCtx` — 15 call sites. Mechanical refactor. |
|
||||
| L0 auto-inject adds latency to every turn | MEDIUM | Memory index query <10ms. Skip if no memory (hasMemory flag). Threshold prevents injection for low-relevance messages. |
|
||||
| Provider template variants diverge over time | LOW | Variants inherit from default, override only specific blocks. Test all variants in CI. |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Template injection:** PromptConfig data (PersonaContent, InstructionContent) comes from DB. Template uses `{{ }}` not `{{ | html }}`. Since output goes to LLM (not browser), HTML escaping not needed. But ensure no Go template directives in user content — use `{{ printf "%s" .Content }}` for raw pass-through.
|
||||
- **Workspace enforcement:** WorkspaceSectionData.EnforcementMsg must be generated server-side, not from user input. Template renders it verbatim.
|
||||
- **ACL bypass:** HybridSkillSearcher MUST filter AFTER search, not before (embedding search doesn't support WHERE clauses). Verify: `ListAccessible()` called on search results.
|
||||
- **Tool capability lying:** Tools self-declare capabilities. PolicyEngine should cross-check: tools in "fs" group claiming "read-only" but actually writing. Add assertion in tests.
|
||||
-923
@@ -1,923 +0,0 @@
|
||||
# Phase 5: Orchestration & Intelligence Design
|
||||
|
||||
## Context Links
|
||||
- Brainstorm: `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md` (sections 11, 15)
|
||||
- Architecture report: `plans/reports/Explore-260406-thorough-v3-architecture.md` (section 8)
|
||||
- Agent link store: `internal/store/agent_link_store.go`
|
||||
- Current subagent: `internal/tools/subagent.go`, `internal/tools/subagent_exec.go`
|
||||
- Current team tools: `internal/tools/team_tool.go`, `internal/tools/team_tool_backend.go`
|
||||
- Event bus: `internal/bus/bus.go`, `internal/bus/types.go`
|
||||
- Phase 4: `phase-04-system-integration-design.md` (WorkspaceContext, ToolCapability, PolicyEngine)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1 (builds on Phase 4 integration layer)
|
||||
- **Status:** Complete
|
||||
- **Effort:** 6h design
|
||||
|
||||
Designs 4 interconnected systems:
|
||||
1. **Delegate tool** — lightweight inter-agent delegation via agent_links
|
||||
2. **Orchestration mode config** — layered: spawn always, delegate base, team optional
|
||||
3. **Self-evolution** — progressive 3-stage rollout (metrics -> suggestions -> auto-adapt)
|
||||
4. **EventBus integration** — orchestration + evolution events
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. Agent Links infrastructure exists (`agent_link_store.go`) with `CanDelegate()`, `GetLinkBetween()`, `DelegateTargets()`, even `SearchDelegateTargetsByEmbedding()` — but NO tool uses it. Full plumbing ready, needs ~100 LOC tool wrapper.
|
||||
2. Current orchestration: spawn (self-clone, ~300 LOC) + team_tasks (9,400 LOC, 18 actions). No middle ground. Simple delegation requires team overhead or spawn limitations.
|
||||
3. Self-evolution is greenfield. No metrics infrastructure exists. Design must be additive — no existing behavior changes in Stage 1.
|
||||
4. EventBus (`bus.MessageBus`) is channel-oriented (Inbound/Outbound messages). Broadcast events are typed but payloads are `any`. V3 needs structured event types for orchestration lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- F1: `delegate` tool — agent delegates task to linked agent, async or sync
|
||||
- F2: Delegate permission checked via existing `agentLinkStore.CanDelegate()`
|
||||
- F3: Delegatee uses own workspace + optional shared area + read-only exports
|
||||
- F4: Orchestration modes configurable per tenant (default) + per agent (override)
|
||||
- F5: System prompt only shows tools for active orchestration mode
|
||||
- F6: Self-evolution Stage 1: record metrics (retrieval quality, tool effectiveness, response feedback)
|
||||
- F7: Self-evolution Stage 2: analyze metrics, generate suggestions, admin review UI
|
||||
- F8: Self-evolution Stage 3: auto-adapt with guardrails + rollback
|
||||
- F9: All orchestration lifecycle events published via EventBus
|
||||
|
||||
### Non-Functional
|
||||
- NF1: Delegate tool adds <5ms overhead over direct agent invocation
|
||||
- NF2: Metrics recording non-blocking (goroutine, never delays response)
|
||||
- NF3: Self-evolution stages independently toggleable (feature flags)
|
||||
- NF4: Backward compatible: existing spawn + team_tasks unaffected
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### A. Delegate Tool
|
||||
|
||||
#### Tool Interface
|
||||
|
||||
```go
|
||||
// DelegateTool enables inter-agent task delegation via agent_links.
|
||||
// Parameters:
|
||||
// agent_key (required): target agent key (from known delegate targets)
|
||||
// task (required): task description for the delegatee
|
||||
// mode (optional): "async" (default) or "sync"
|
||||
// context (optional): additional context string passed to delegatee
|
||||
// files (optional): list of file paths to export (read-only for delegatee)
|
||||
|
||||
type DelegateTool struct {
|
||||
agentLinkStore store.AgentLinkStore
|
||||
agentStore store.AgentStore
|
||||
runner AgentRunner // interface to invoke agent loop
|
||||
bus bus.EventPublisher
|
||||
}
|
||||
|
||||
func (t *DelegateTool) Name() string { return "delegate" }
|
||||
|
||||
func (t *DelegateTool) Metadata() ToolMetadata {
|
||||
return ToolMetadata{
|
||||
Name: "delegate",
|
||||
Capabilities: []ToolCapability{CapAsync},
|
||||
Group: "orchestration",
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DelegateTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"agent_key": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Key of the agent to delegate to (from your known delegate targets)",
|
||||
},
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Task description for the delegatee",
|
||||
},
|
||||
"mode": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"async", "sync"},
|
||||
"default": "async",
|
||||
"description": "async: fire-and-forget with result notification. sync: wait for completion.",
|
||||
},
|
||||
"context": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Additional context passed to delegatee's system prompt",
|
||||
},
|
||||
"files": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
"description": "File paths (relative to your workspace) to share read-only with delegatee",
|
||||
},
|
||||
},
|
||||
"required": []string{"agent_key", "task"},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Execution Flow
|
||||
|
||||
```
|
||||
delegate(agent_key="analyst", task="analyze Q1 data", mode="async")
|
||||
|
|
||||
v
|
||||
1. Resolve target: agentStore.GetByKey(agent_key)
|
||||
-> fail if agent not found or inactive
|
||||
|
|
||||
v
|
||||
2. Permission check: agentLinkStore.CanDelegate(fromAgentID, toAgentID)
|
||||
-> fail if no active link
|
||||
|
|
||||
v
|
||||
3. Get link config: agentLinkStore.GetLinkBetween(from, to)
|
||||
-> read Settings JSON for workspace config
|
||||
|
|
||||
v
|
||||
4. Build WorkspaceContext for delegatee:
|
||||
- ActivePath: delegatee's own workspace (resolved normally)
|
||||
- SharedPath: /data/delegates/{linkID}/shared (created if not exists)
|
||||
- ReadOnlyPaths: exported files from delegator (validated against workspace)
|
||||
- Scope: "delegate"
|
||||
|
|
||||
v
|
||||
5. Build delegation request:
|
||||
- ExtraPrompt: "Delegated task from {source_agent_key}: {task}\n{context}"
|
||||
- WorkspaceContext: as built above
|
||||
- Mode: PromptMinimal (subagent-style, reduced sections)
|
||||
|
|
||||
v
|
||||
6. Execute:
|
||||
async mode:
|
||||
- Spawn goroutine via scheduler lane
|
||||
- Return immediately: "Task delegated to {agent_key}. You'll be notified on completion."
|
||||
- On completion: bus.Broadcast(delegate.completed event)
|
||||
- Result delivered via message bus (same as spawn announce)
|
||||
sync mode:
|
||||
- Block until delegatee completes (with timeout from link.Settings)
|
||||
- Return delegatee's final response directly
|
||||
|
|
||||
v
|
||||
7. Emit event: bus.Broadcast("delegate.sent", DelegateSentPayload{...})
|
||||
```
|
||||
|
||||
#### Workspace Model for Delegation
|
||||
|
||||
```go
|
||||
// DelegateWorkspaceConfig is stored in agent_link.settings JSON.
|
||||
type DelegateWorkspaceConfig struct {
|
||||
// SharedPath: shared directory both agents can read/write.
|
||||
// Relative to data dir. Default: "delegates/{linkID}/shared"
|
||||
SharedPath string `json:"shared_path,omitempty"`
|
||||
|
||||
// SourceExports: glob patterns of delegator files shared read-only.
|
||||
// Relative to delegator's workspace. E.g., ["docs/", "data/results/"]
|
||||
SourceExports []string `json:"source_exports,omitempty"`
|
||||
|
||||
// SyncTimeout: max wait time for sync mode (seconds). Default: 300
|
||||
SyncTimeout int `json:"sync_timeout,omitempty"`
|
||||
|
||||
// MaxConcurrent: max concurrent delegations on this link. 0 = unlimited.
|
||||
// Already exists as AgentLinkData.MaxConcurrent field.
|
||||
}
|
||||
```
|
||||
|
||||
No new DB tables. Config stored in existing `agent_links.settings` JSONB column.
|
||||
|
||||
#### AgentRunner Interface
|
||||
|
||||
```go
|
||||
// AgentRunner abstracts invoking an agent's loop for delegation.
|
||||
// Implemented by the gateway's run dispatcher.
|
||||
type AgentRunner interface {
|
||||
// RunDelegation executes a delegation task and returns the result.
|
||||
// ctx carries workspace context, tenant scope, timeout.
|
||||
RunDelegation(ctx context.Context, req DelegationRequest) (*DelegationResult, error)
|
||||
}
|
||||
|
||||
type DelegationRequest struct {
|
||||
TargetAgentID uuid.UUID
|
||||
SourceAgentID uuid.UUID
|
||||
LinkID uuid.UUID
|
||||
Task string
|
||||
Context string
|
||||
WorkspaceCtx *WorkspaceContext
|
||||
UserID string // inherit from delegator's user context
|
||||
TenantID uuid.UUID
|
||||
SyncTimeout time.Duration // 0 = async
|
||||
}
|
||||
|
||||
type DelegationResult struct {
|
||||
Response string // delegatee's final text response
|
||||
ToolCalls int // number of tool calls made
|
||||
Duration time.Duration
|
||||
Error error
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. Orchestration Mode Configuration
|
||||
|
||||
#### Mode Definitions
|
||||
|
||||
```go
|
||||
// OrchestrationMode controls which inter-agent tools are available.
|
||||
type OrchestrationMode string
|
||||
|
||||
const (
|
||||
// ModeSpawn: self-clone only. spawn tool available.
|
||||
// Simplest mode. Agent can only create copies of itself for background work.
|
||||
ModeSpawn OrchestrationMode = "spawn"
|
||||
|
||||
// ModeDelegate: agent links + spawn. delegate tool available.
|
||||
// Agent can send tasks to linked agents. Base inter-agent mode.
|
||||
ModeDelegate OrchestrationMode = "delegate"
|
||||
|
||||
// ModeTeam: full team tasks + delegate + spawn. team_tasks tool available.
|
||||
// Full lifecycle: review, approval, progress tracking.
|
||||
// Only available when agent is member of an active team.
|
||||
ModeTeam OrchestrationMode = "team"
|
||||
)
|
||||
```
|
||||
|
||||
#### Configuration Layering
|
||||
|
||||
```go
|
||||
// OrchestrationConfig controls orchestration behavior.
|
||||
type OrchestrationConfig struct {
|
||||
// Mode: active orchestration mode.
|
||||
// Tenant sets default, agent overrides.
|
||||
Mode OrchestrationMode `json:"orchestration_mode"`
|
||||
}
|
||||
```
|
||||
|
||||
**Storage:** No new DB columns. Uses existing config mechanisms:
|
||||
- Tenant default: `tenant_settings.orchestration_mode` (existing JSONB settings column on tenants table)
|
||||
- Agent override: `agents.settings -> 'orchestration_mode'` (existing JSONB settings column)
|
||||
|
||||
**Resolution order:**
|
||||
```
|
||||
1. Agent settings.orchestration_mode (if set)
|
||||
2. Tenant settings.orchestration_mode (if set)
|
||||
3. Default: "spawn"
|
||||
```
|
||||
|
||||
#### Tool Visibility by Mode
|
||||
|
||||
| Mode | spawn | delegate | team_tasks |
|
||||
|------|-------|----------|------------|
|
||||
| spawn | yes | no | no |
|
||||
| delegate | yes | yes | no |
|
||||
| team | yes | yes | yes |
|
||||
|
||||
**Implementation:** PolicyEngine checks orchestration mode during FilterTools:
|
||||
|
||||
```go
|
||||
// In PolicyEngine.FilterTools(), after existing 7-step pipeline:
|
||||
func (pe *PolicyEngine) applyOrchestrationMode(
|
||||
allowed []string,
|
||||
mode OrchestrationMode,
|
||||
isTeamMember bool,
|
||||
) []string {
|
||||
switch mode {
|
||||
case ModeSpawn:
|
||||
allowed = subtractSet(allowed, []string{"delegate", "team_tasks"})
|
||||
case ModeDelegate:
|
||||
allowed = subtractSet(allowed, []string{"team_tasks"})
|
||||
case ModeTeam:
|
||||
if !isTeamMember {
|
||||
// Downgrade to delegate if not in team
|
||||
allowed = subtractSet(allowed, []string{"team_tasks"})
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
```
|
||||
|
||||
#### System Prompt Integration
|
||||
|
||||
PromptConfig (from Phase 4) carries orchestration info:
|
||||
|
||||
```go
|
||||
// Added to PromptConfig:
|
||||
type OrchestrationSectionData struct {
|
||||
Mode OrchestrationMode
|
||||
DelegateTargets []DelegateTargetEntry // from agentLinkStore.DelegateTargets()
|
||||
TeamContext *TeamSectionData // only if ModeTeam + team member
|
||||
}
|
||||
|
||||
type DelegateTargetEntry struct {
|
||||
AgentKey string
|
||||
DisplayName string
|
||||
Description string
|
||||
}
|
||||
```
|
||||
|
||||
Template renders delegate targets when mode is delegate/team:
|
||||
|
||||
```
|
||||
{{- define "orchestration" -}}
|
||||
{{- if eq .Mode "delegate" "team" }}
|
||||
# Delegation
|
||||
You can delegate tasks to these agents:
|
||||
{{- range .DelegateTargets }}
|
||||
- **{{ .AgentKey }}**: {{ .Description }}
|
||||
{{- end }}
|
||||
Use `delegate(agent_key, task)` to send work.
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. Self-Evolution Design (Progressive)
|
||||
|
||||
#### Stage 1: Metrics Infrastructure (v3.0)
|
||||
|
||||
##### DB Schema
|
||||
|
||||
```sql
|
||||
-- New table: agent_evolution_metrics
|
||||
CREATE TABLE agent_evolution_metrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
session_key TEXT NOT NULL,
|
||||
|
||||
metric_type TEXT NOT NULL, -- 'retrieval', 'tool', 'feedback'
|
||||
metric_key TEXT NOT NULL, -- specific metric (e.g., tool name, retrieval query)
|
||||
value JSONB NOT NULL, -- metric-specific payload
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for efficient querying
|
||||
CREATE INDEX idx_evo_metrics_agent_type ON agent_evolution_metrics(agent_id, metric_type);
|
||||
CREATE INDEX idx_evo_metrics_created ON agent_evolution_metrics(created_at);
|
||||
CREATE INDEX idx_evo_metrics_tenant ON agent_evolution_metrics(tenant_id);
|
||||
|
||||
-- Partition-ready: created_at is in schema for future range partitioning
|
||||
-- For now, TTL cleanup via cron: DELETE WHERE created_at < NOW() - INTERVAL '90 days'
|
||||
```
|
||||
|
||||
##### Metric Types
|
||||
|
||||
```go
|
||||
// MetricType constants
|
||||
const (
|
||||
MetricRetrieval MetricType = "retrieval"
|
||||
MetricTool MetricType = "tool"
|
||||
MetricFeedback MetricType = "feedback"
|
||||
)
|
||||
|
||||
// RetrievalMetricValue records per-retrieval quality.
|
||||
type RetrievalMetricValue struct {
|
||||
Query string `json:"query"`
|
||||
ResultCount int `json:"result_count"`
|
||||
TopScore float64 `json:"top_score"`
|
||||
UsedInReply bool `json:"used_in_reply"` // heuristic: result text appears in LLM output
|
||||
Source string `json:"source"` // "memory_search", "kg_search", "auto_inject"
|
||||
}
|
||||
|
||||
// ToolMetricValue records per-tool-call effectiveness.
|
||||
type ToolMetricValue struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Success bool `json:"success"` // !result.IsError
|
||||
Duration time.Duration `json:"duration_ms"`
|
||||
ResultSize int `json:"result_size"` // bytes
|
||||
Capability string `json:"capability"` // from ToolMetadata
|
||||
}
|
||||
|
||||
// FeedbackMetricValue records implicit satisfaction signal.
|
||||
type FeedbackMetricValue struct {
|
||||
Signal string `json:"signal"` // "correction", "followup", "thanks", "new_topic"
|
||||
TurnGap int `json:"turn_gap"` // turns between response and signal
|
||||
PreviousTool string `json:"previous_tool"` // last tool used before signal
|
||||
}
|
||||
```
|
||||
|
||||
##### Heuristic: "Was retrieval result used?"
|
||||
|
||||
```
|
||||
After LLM response:
|
||||
1. Collect all retrieval results from this turn (memory_search, auto-inject L0)
|
||||
2. For each result, check if any 3+ word substring appears in LLM output
|
||||
3. Mark used_in_reply = true if found
|
||||
4. Record metric with used_in_reply flag
|
||||
```
|
||||
|
||||
Simple substring check. Not perfect but sufficient for Stage 1 metrics. No LLM call needed.
|
||||
|
||||
##### Implicit Satisfaction Signals
|
||||
|
||||
```
|
||||
After user's next message:
|
||||
1. If message is a correction ("no", "that's wrong", "I meant..."): signal = "correction"
|
||||
2. If message continues topic (similar embedding): signal = "followup"
|
||||
3. If message changes topic entirely: signal = "new_topic"
|
||||
4. If message is appreciation ("thanks", "perfect"): signal = "thanks"
|
||||
5. Record feedback metric with previous turn's tool context
|
||||
```
|
||||
|
||||
Classification via keyword matching + embedding similarity. No LLM call.
|
||||
|
||||
##### Metrics Store Interface
|
||||
|
||||
```go
|
||||
// EvolutionMetricsStore manages self-evolution metrics.
|
||||
type EvolutionMetricsStore interface {
|
||||
// RecordMetric stores a single metric (non-blocking, goroutine-safe).
|
||||
RecordMetric(ctx context.Context, metric EvolutionMetric) error
|
||||
|
||||
// QueryMetrics returns metrics for analysis.
|
||||
QueryMetrics(ctx context.Context, agentID uuid.UUID, metricType MetricType, since time.Time, limit int) ([]EvolutionMetric, error)
|
||||
|
||||
// AggregateToolMetrics returns per-tool success rate, avg duration, call count.
|
||||
AggregateToolMetrics(ctx context.Context, agentID uuid.UUID, since time.Time) ([]ToolAggregate, error)
|
||||
|
||||
// AggregateRetrievalMetrics returns per-source usage rate.
|
||||
AggregateRetrievalMetrics(ctx context.Context, agentID uuid.UUID, since time.Time) ([]RetrievalAggregate, error)
|
||||
|
||||
// Cleanup removes metrics older than TTL.
|
||||
Cleanup(ctx context.Context, olderThan time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type EvolutionMetric struct {
|
||||
ID uuid.UUID
|
||||
TenantID uuid.UUID
|
||||
AgentID uuid.UUID
|
||||
SessionKey string
|
||||
MetricType MetricType
|
||||
MetricKey string
|
||||
Value json.RawMessage // serialized *MetricValue
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ToolAggregate struct {
|
||||
ToolName string
|
||||
CallCount int
|
||||
SuccessRate float64
|
||||
AvgDuration time.Duration
|
||||
}
|
||||
|
||||
type RetrievalAggregate struct {
|
||||
Source string // "memory_search", "kg_search", "auto_inject"
|
||||
QueryCount int
|
||||
UsageRate float64 // fraction of results used in reply
|
||||
AvgScore float64
|
||||
}
|
||||
```
|
||||
|
||||
##### Pipeline Integration
|
||||
|
||||
Metrics recorded in ObserveStage (Phase 2 pipeline):
|
||||
|
||||
```go
|
||||
// In ObserveStage.Execute():
|
||||
// After tool result processing:
|
||||
if toolResult != nil {
|
||||
go metricsStore.RecordMetric(ctx, EvolutionMetric{
|
||||
MetricType: MetricTool,
|
||||
MetricKey: toolResult.ToolName,
|
||||
Value: marshalToolMetric(toolResult),
|
||||
})
|
||||
}
|
||||
|
||||
// After retrieval in ContextStage:
|
||||
if len(l0Results) > 0 {
|
||||
go metricsStore.RecordMetric(ctx, EvolutionMetric{
|
||||
MetricType: MetricRetrieval,
|
||||
MetricKey: "auto_inject",
|
||||
Value: marshalRetrievalMetric(query, l0Results),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Always non-blocking (`go` goroutine). Metrics are best-effort — dropped on error, never block agent loop.
|
||||
|
||||
#### Stage 2: Suggestion Engine (v3.1)
|
||||
|
||||
##### DB Schema
|
||||
|
||||
```sql
|
||||
-- New table: agent_evolution_suggestions
|
||||
CREATE TABLE agent_evolution_suggestions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
|
||||
suggestion_type TEXT NOT NULL, -- 'threshold', 'tool_order', 'skill_add', 'memory_prune'
|
||||
suggestion TEXT NOT NULL, -- human-readable suggestion
|
||||
rationale TEXT NOT NULL, -- data-driven reasoning
|
||||
parameters JSONB, -- machine-readable params for auto-apply
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'approved', 'rejected', 'applied', 'rolled_back'
|
||||
reviewed_by TEXT, -- admin user who reviewed
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_evo_suggestions_agent ON agent_evolution_suggestions(agent_id, status);
|
||||
CREATE INDEX idx_evo_suggestions_tenant ON agent_evolution_suggestions(tenant_id);
|
||||
```
|
||||
|
||||
##### Suggestion Types
|
||||
|
||||
```go
|
||||
type SuggestionType string
|
||||
|
||||
const (
|
||||
SuggestThreshold SuggestionType = "threshold" // adjust retrieval threshold
|
||||
SuggestToolOrder SuggestionType = "tool_order" // reorder tool list for this agent
|
||||
SuggestSkillAdd SuggestionType = "skill_add" // recommend adding a skill
|
||||
SuggestMemoryPrune SuggestionType = "memory_prune" // prune low-value memory
|
||||
)
|
||||
|
||||
// SuggestionParams is the machine-readable payload for auto-apply.
|
||||
// Each SuggestionType has its own params structure.
|
||||
type ThresholdParams struct {
|
||||
Param string `json:"param"` // "retrieval_threshold", "l0_max_tokens"
|
||||
OldValue float64 `json:"old_value"`
|
||||
NewValue float64 `json:"new_value"`
|
||||
}
|
||||
|
||||
type ToolOrderParams struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Action string `json:"action"` // "promote", "demote", "suggest_removal"
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
```
|
||||
|
||||
##### Analysis Rules (Examples)
|
||||
|
||||
```
|
||||
Rule 1: Low retrieval usage
|
||||
IF retrieval.usage_rate < 0.2 over 7 days AND query_count > 50
|
||||
THEN suggest: "Raise relevance_threshold from {current} to {current+0.1}"
|
||||
REASON: "Only 20% of auto-injected memory results appear in responses"
|
||||
|
||||
Rule 2: Tool never succeeds
|
||||
IF tool.success_rate < 0.1 over 7 days AND call_count > 20
|
||||
THEN suggest: "Consider removing {tool_name} or fixing configuration"
|
||||
REASON: "{tool_name} failed 90% of {call_count} calls"
|
||||
|
||||
Rule 3: Repeated tool usage
|
||||
IF tool.call_count > 100/week for specific tool AND no skill exists
|
||||
THEN suggest: "Add skill for {common_task_pattern}"
|
||||
REASON: "{tool_name} called {count} times this week, mostly for {pattern}"
|
||||
|
||||
Rule 4: Memory bloat
|
||||
IF episodic_count > 500 AND avg_usage_rate < 0.05
|
||||
THEN suggest: "Prune episodic memories older than 60 days"
|
||||
REASON: "95% of older episodic summaries never retrieved"
|
||||
```
|
||||
|
||||
##### Suggestion Store Interface
|
||||
|
||||
```go
|
||||
type EvolutionSuggestionStore interface {
|
||||
CreateSuggestion(ctx context.Context, s EvolutionSuggestion) error
|
||||
ListSuggestions(ctx context.Context, agentID uuid.UUID, status string, limit int) ([]EvolutionSuggestion, error)
|
||||
UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status string, reviewedBy string) error
|
||||
GetSuggestion(ctx context.Context, id uuid.UUID) (*EvolutionSuggestion, error)
|
||||
}
|
||||
|
||||
type EvolutionSuggestion struct {
|
||||
ID uuid.UUID
|
||||
TenantID uuid.UUID
|
||||
AgentID uuid.UUID
|
||||
SuggestionType SuggestionType
|
||||
Suggestion string
|
||||
Rationale string
|
||||
Parameters json.RawMessage
|
||||
Status string
|
||||
ReviewedBy string
|
||||
ReviewedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
##### Suggestion Engine Interface
|
||||
|
||||
```go
|
||||
// SuggestionEngine analyzes metrics and generates suggestions.
|
||||
// Runs as periodic cron job (configurable interval, default: daily).
|
||||
type SuggestionEngine interface {
|
||||
// Analyze runs all rules for an agent and creates pending suggestions.
|
||||
Analyze(ctx context.Context, agentID uuid.UUID) ([]EvolutionSuggestion, error)
|
||||
|
||||
// AnalyzeAll runs analysis for all agents in tenant.
|
||||
AnalyzeAll(ctx context.Context, tenantID uuid.UUID) error
|
||||
}
|
||||
|
||||
// RuleSuggestionEngine implements SuggestionEngine with configurable rules.
|
||||
type RuleSuggestionEngine struct {
|
||||
metricsStore EvolutionMetricsStore
|
||||
suggestionStore EvolutionSuggestionStore
|
||||
rules []AnalysisRule
|
||||
}
|
||||
|
||||
// AnalysisRule is a single suggestion rule.
|
||||
type AnalysisRule interface {
|
||||
// Evaluate checks if the rule fires for the given agent metrics.
|
||||
// Returns nil if rule doesn't apply.
|
||||
Evaluate(ctx context.Context, agentID uuid.UUID, metrics AnalysisInput) (*EvolutionSuggestion, error)
|
||||
}
|
||||
|
||||
type AnalysisInput struct {
|
||||
ToolAggregates []ToolAggregate
|
||||
RetrievalAggregates []RetrievalAggregate
|
||||
FeedbackSummary FeedbackSummary
|
||||
AnalysisPeriod time.Duration // lookback window (default 7 days)
|
||||
}
|
||||
```
|
||||
|
||||
#### Stage 3: Auto-Adaptation (v3.2+)
|
||||
|
||||
##### Guardrails
|
||||
|
||||
```go
|
||||
// AdaptationGuardrails control auto-adaptation limits.
|
||||
type AdaptationGuardrails struct {
|
||||
// MaxDeltaPerCycle: max parameter change per adaptation cycle.
|
||||
// E.g., threshold can only change by +/- 0.1 per cycle.
|
||||
MaxDeltaPerCycle float64 `json:"max_delta_per_cycle"`
|
||||
|
||||
// MinDataPoints: minimum metrics before adaptation allowed.
|
||||
MinDataPoints int `json:"min_data_points"`
|
||||
|
||||
// RollbackOnDrop: if quality metric drops by this %, rollback.
|
||||
RollbackOnDrop float64 `json:"rollback_on_drop_pct"` // e.g., 20.0 = 20%
|
||||
|
||||
// AdminOverride: admin can lock parameters (prevent auto-change).
|
||||
LockedParams []string `json:"locked_params,omitempty"`
|
||||
}
|
||||
|
||||
// Default guardrails
|
||||
var DefaultGuardrails = AdaptationGuardrails{
|
||||
MaxDeltaPerCycle: 0.1,
|
||||
MinDataPoints: 100,
|
||||
RollbackOnDrop: 20.0,
|
||||
}
|
||||
```
|
||||
|
||||
##### Adaptation Flow
|
||||
|
||||
```
|
||||
1. SuggestionEngine.Analyze() produces suggestion with status="pending"
|
||||
2. If auto_adapt enabled for this agent:
|
||||
a. Check guardrails (min data points, max delta, locked params)
|
||||
b. If passes: apply parameters, set status="applied"
|
||||
c. Record baseline quality metric
|
||||
3. After 7 days: compare quality metric to baseline
|
||||
a. If quality dropped > RollbackOnDrop%: rollback, set status="rolled_back"
|
||||
b. If quality stable/improved: keep, emit event
|
||||
4. Admin can always: approve pending, reject, rollback applied, lock params
|
||||
```
|
||||
|
||||
##### Auto-Adapt Store (Extension of Suggestion Store)
|
||||
|
||||
```go
|
||||
// Added to EvolutionSuggestionStore for Stage 3:
|
||||
type EvolutionAdaptStore interface {
|
||||
EvolutionSuggestionStore
|
||||
|
||||
// ApplySuggestion applies a suggestion's parameters to agent config.
|
||||
ApplySuggestion(ctx context.Context, suggestionID uuid.UUID) error
|
||||
|
||||
// RollbackSuggestion reverts an applied suggestion.
|
||||
RollbackSuggestion(ctx context.Context, suggestionID uuid.UUID) error
|
||||
|
||||
// GetAdaptationHistory returns applied/rolled-back suggestions for audit.
|
||||
GetAdaptationHistory(ctx context.Context, agentID uuid.UUID, limit int) ([]EvolutionSuggestion, error)
|
||||
}
|
||||
```
|
||||
|
||||
##### Feature Flags
|
||||
|
||||
```go
|
||||
// Feature flags in agent settings JSON (agents.settings column)
|
||||
type SelfEvolutionFlags struct {
|
||||
MetricsEnabled bool `json:"self_evolution_metrics"` // Stage 1 (default: true for v3)
|
||||
SuggestionsEnabled bool `json:"self_evolution_suggestions"` // Stage 2 (default: false)
|
||||
AutoAdaptEnabled bool `json:"self_evolution_auto_adapt"` // Stage 3 (default: false)
|
||||
}
|
||||
```
|
||||
|
||||
No new DB columns. Flags stored in existing `agents.settings` JSONB.
|
||||
|
||||
---
|
||||
|
||||
### D. EventBus Integration
|
||||
|
||||
#### New Event Topics
|
||||
|
||||
```go
|
||||
// Orchestration events
|
||||
const (
|
||||
TopicDelegateSent = "delegate:sent"
|
||||
TopicDelegateCompleted = "delegate:completed"
|
||||
TopicDelegateFailed = "delegate:failed"
|
||||
)
|
||||
|
||||
// Self-evolution events
|
||||
const (
|
||||
TopicMetricRecorded = "evolution:metric"
|
||||
TopicSuggestionCreated = "evolution:suggestion"
|
||||
TopicAdaptationApplied = "evolution:adapted"
|
||||
TopicAdaptationRolledBack = "evolution:rollback"
|
||||
)
|
||||
```
|
||||
|
||||
#### Typed Event Payloads
|
||||
|
||||
```go
|
||||
// DelegateSentPayload emitted when delegate tool fires.
|
||||
type DelegateSentPayload struct {
|
||||
SourceAgentID uuid.UUID `json:"source_agent_id"`
|
||||
TargetAgentID uuid.UUID `json:"target_agent_id"`
|
||||
LinkID uuid.UUID `json:"link_id"`
|
||||
Task string `json:"task"`
|
||||
Mode string `json:"mode"` // "async" or "sync"
|
||||
SessionKey string `json:"session_key"`
|
||||
}
|
||||
|
||||
// DelegateCompletedPayload emitted when delegatee finishes.
|
||||
type DelegateCompletedPayload struct {
|
||||
SourceAgentID uuid.UUID `json:"source_agent_id"`
|
||||
TargetAgentID uuid.UUID `json:"target_agent_id"`
|
||||
LinkID uuid.UUID `json:"link_id"`
|
||||
Duration time.Duration `json:"duration_ms"`
|
||||
ToolCalls int `json:"tool_calls"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DelegateFailedPayload emitted on delegation failure.
|
||||
type DelegateFailedPayload struct {
|
||||
SourceAgentID uuid.UUID `json:"source_agent_id"`
|
||||
TargetAgentID uuid.UUID `json:"target_agent_id"`
|
||||
LinkID uuid.UUID `json:"link_id"`
|
||||
Reason string `json:"reason"` // "permission_denied", "agent_inactive", "timeout"
|
||||
}
|
||||
|
||||
// MetricRecordedPayload emitted on each metric write.
|
||||
type MetricRecordedPayload struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
MetricType MetricType `json:"metric_type"`
|
||||
MetricKey string `json:"metric_key"`
|
||||
}
|
||||
|
||||
// SuggestionCreatedPayload emitted when suggestion engine generates one.
|
||||
type SuggestionCreatedPayload struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
SuggestionID uuid.UUID `json:"suggestion_id"`
|
||||
SuggestionType SuggestionType `json:"suggestion_type"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
```
|
||||
|
||||
#### Event Subscribers
|
||||
|
||||
```go
|
||||
// Registered at gateway startup:
|
||||
|
||||
// 1. Delegation result delivery — notifies source agent when async delegation completes
|
||||
bus.Subscribe("delegate-result-delivery", func(e bus.Event) {
|
||||
if e.Name == TopicDelegateCompleted {
|
||||
// Deliver result to source agent's session via sessions_send mechanism
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Metrics recording — non-blocking metric persistence
|
||||
bus.Subscribe("evolution-metrics-recorder", func(e bus.Event) {
|
||||
if e.Name == TopicMetricRecorded {
|
||||
// Already persisted by goroutine in ObserveStage.
|
||||
// Subscriber only for UI real-time updates (WebSocket broadcast).
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Suggestion notification — notify admin of new suggestions
|
||||
bus.Subscribe("evolution-suggestion-notify", func(e bus.Event) {
|
||||
if e.Name == TopicSuggestionCreated {
|
||||
// Broadcast to WebSocket for admin UI notification
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to Modify
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/bus/types.go` | Add orchestration + evolution topic constants + payload types |
|
||||
| `internal/tools/policy.go` | Add applyOrchestrationMode() to FilterTools |
|
||||
| `internal/agent/loop.go` | Wire metrics recording in observe phase |
|
||||
| `internal/agent/systemprompt.go` | Add orchestration section to prompt config |
|
||||
| `internal/store/agent_link_store.go` | (no change — already has full interface) |
|
||||
| `cmd/gateway.go` | Register delegate tool, event subscribers, metrics cron |
|
||||
|
||||
### Files to Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/tools/delegate_tool.go` | DelegateTool implementation |
|
||||
| `internal/tools/delegate_workspace.go` | DelegateWorkspaceConfig, workspace setup |
|
||||
| `internal/agent/orchestration_mode.go` | OrchestrationMode types, resolution logic |
|
||||
| `internal/agent/agent_runner.go` | AgentRunner interface for delegation dispatch |
|
||||
| `internal/store/evolution_store.go` | EvolutionMetricsStore + EvolutionSuggestionStore interfaces |
|
||||
| `internal/store/pg/evolution_metrics.go` | PG implementation of EvolutionMetricsStore |
|
||||
| `internal/store/pg/evolution_suggestions.go` | PG implementation of EvolutionSuggestionStore |
|
||||
| `internal/agent/evolution_metrics.go` | Metric types + recording helpers |
|
||||
| `internal/agent/evolution_suggestion_engine.go` | SuggestionEngine + AnalysisRule implementations |
|
||||
| `internal/agent/evolution_guardrails.go` | AdaptationGuardrails + auto-adapt logic |
|
||||
|
||||
---
|
||||
|
||||
## Design Steps
|
||||
|
||||
1. Define DelegateTool interface + parameters schema
|
||||
2. Design delegate execution flow (async + sync modes)
|
||||
3. Define DelegateWorkspaceConfig in agent_link.settings
|
||||
4. Define AgentRunner interface for delegation dispatch
|
||||
5. Define OrchestrationMode enum + resolution logic (tenant -> agent)
|
||||
6. Design tool visibility filtering by orchestration mode
|
||||
7. Design orchestration section for system prompt template
|
||||
8. Define agent_evolution_metrics schema + indexes
|
||||
9. Define metric types (retrieval, tool, feedback) with value structs
|
||||
10. Define EvolutionMetricsStore interface + aggregate methods
|
||||
11. Design metrics recording integration in ObserveStage
|
||||
12. Define agent_evolution_suggestions schema
|
||||
13. Define SuggestionEngine + AnalysisRule interfaces
|
||||
14. Design analysis rules (4 initial rules)
|
||||
15. Define AdaptationGuardrails + auto-adapt flow
|
||||
16. Define all EventBus topics + typed payloads
|
||||
17. Design event subscriber wiring at startup
|
||||
|
||||
---
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] A1: DelegateTool interface + parameters
|
||||
- [ ] A2: Delegate execution flow (async/sync)
|
||||
- [ ] A3: DelegateWorkspaceConfig schema
|
||||
- [ ] A4: AgentRunner interface
|
||||
- [ ] A5: Permission flow (CanDelegate + GetLinkBetween)
|
||||
- [ ] B1: OrchestrationMode enum + constants
|
||||
- [ ] B2: Mode resolution (tenant default + agent override)
|
||||
- [ ] B3: Tool visibility by mode in PolicyEngine
|
||||
- [ ] B4: Orchestration section in prompt template
|
||||
- [ ] C1: agent_evolution_metrics DDL + indexes
|
||||
- [ ] C2: Metric value types (retrieval, tool, feedback)
|
||||
- [ ] C3: EvolutionMetricsStore interface
|
||||
- [ ] C4: Retrieval "used in reply" heuristic
|
||||
- [ ] C5: Implicit satisfaction signal classification
|
||||
- [ ] C6: agent_evolution_suggestions DDL
|
||||
- [ ] C7: SuggestionEngine + AnalysisRule interfaces
|
||||
- [ ] C8: Initial 4 analysis rules
|
||||
- [ ] C9: AdaptationGuardrails + auto-adapt flow
|
||||
- [ ] C10: Feature flags in agent settings
|
||||
- [ ] D1: EventBus topic constants
|
||||
- [ ] D2: Typed event payloads (delegate, evolution)
|
||||
- [ ] D3: Event subscriber design (result delivery, notifications)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. DelegateTool ~100 LOC (excluding workspace setup), uses existing agentLinkStore.CanDelegate()
|
||||
2. Orchestration mode resolvable from tenant + agent settings with no new DB columns
|
||||
3. PolicyEngine correctly filters tools by mode (spawn/delegate/team)
|
||||
4. agent_evolution_metrics table handles 10K+ rows/day without index degradation
|
||||
5. Metrics recording never blocks agent loop (goroutine + best-effort)
|
||||
6. SuggestionEngine rules are additive (new rule = implement AnalysisRule interface)
|
||||
7. All orchestration events typed (no `map[string]any` payloads)
|
||||
8. Feature flags independently toggleable per agent
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Delegate tool workspace isolation leaks | HIGH | ReadOnlyPaths enforced by same resolvePath() as all file tools. SharedPath created with restricted permissions. |
|
||||
| Sync delegation timeout blocks caller | MEDIUM | Configurable timeout per link (default 300s). Timeout returns error, does not kill delegatee. |
|
||||
| Metrics table grows unbounded | MEDIUM | TTL cleanup cron (default 90 days). Partitioning-ready schema (created_at). Monitor table size. |
|
||||
| Suggestion engine generates noise | LOW | Minimum data points threshold (100). Suggestions require admin review (Stage 2). Auto-adapt has guardrails (Stage 3). |
|
||||
| Orchestration mode config not discoverable | LOW | Admin UI shows current mode per agent. CLI: goclaw agent info shows mode. |
|
||||
| Self-evolution "learns wrong" | HIGH | Progressive rollout. Stage 1 = metrics only (no behavior change). Stage 2 = human review. Stage 3 = guardrails + rollback. |
|
||||
| EventBus topic explosion | LOW | Strict naming convention. Only add topics with subscribers. No orphan topics. |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Delegation permission:** `CanDelegate()` is the sole gate. No bypass via direct tool call — delegate tool always calls CanDelegate(). Link status "disabled" blocks delegation.
|
||||
- **Cross-tenant delegation:** Agent links are tenant-scoped (both agents must be in same tenant). `agentLinkStore` queries filter by tenant_id from context.
|
||||
- **Delegate workspace isolation:** Delegatee cannot access delegator's full workspace. Only explicitly exported paths (SourceExports) are shared read-only. SharedPath is a new directory, not inside either workspace.
|
||||
- **Metrics data sensitivity:** Metrics contain query text and tool names, not user data or PII. Scoped by tenant_id. Cleanup cron prevents accumulation.
|
||||
- **Auto-adapt scope:** Stage 3 only modifies retrieval parameters (thresholds, weights). Cannot modify tool access, workspace rules, or security settings. Admin can lock any parameter.
|
||||
@@ -1,683 +0,0 @@
|
||||
# Phase 6: Migration & Audit
|
||||
|
||||
## Context Links
|
||||
- Brainstorm: `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md` (sections 16, 17)
|
||||
- Architecture report: `plans/reports/Explore-260406-thorough-v3-architecture.md` (all sections)
|
||||
- Workspace forcing: `plans/reports/Explore-260406-workspace-forcing.md`
|
||||
- Phase 1-5 designs: all prior phase files in this plan
|
||||
- Current migrations: `migrations/000001_init_schema.up.sql` through `migrations/000036_secure_cli_agent_grants.up.sql`
|
||||
- Schema version: `internal/upgrade/version.go` (RequiredSchemaVersion = 36)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1 (blocks implementation start)
|
||||
- **Status:** Complete
|
||||
- **Effort:** 4h design
|
||||
|
||||
Final design phase — comprehensive migration plan, affected file inventory, test matrix, and audit checklist. Ensures no implementation begins without full impact analysis.
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. Current RequiredSchemaVersion = 36 (migration 000036). V3 adds 3 new tables + 2 ALTER TABLE. Next migration = 000037.
|
||||
2. KG tables (000013) lack temporal columns. Adding valid_from/valid_until via ALTER is non-breaking — NULL defaults work.
|
||||
3. Memory system is hybrid: daily .md files in filesystem + memory_documents/memory_chunks in PG. V3 must ingest filesystem files into new episodic_summaries table.
|
||||
4. Agent settings already uses JSONB — all new per-agent configs (retrieval thresholds, orchestration mode, self-evolution flags, dedup thresholds) fit without schema changes.
|
||||
5. No SQLite changes in v3 — design is PG-first per brainstorm decision #17. SQLite compat deferred.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- F1: New tables created via standard golang-migrate files
|
||||
- F2: ALTER TABLE for KG temporal columns with safe defaults
|
||||
- F3: Lazy data migration: v2 data auto-converted on first access
|
||||
- F4: Feature flags per agent for gradual v3 rollout
|
||||
- F5: Full affected file inventory with dependency order
|
||||
- F6: Integration test plan covering all 6 workspace scenarios
|
||||
- F7: Rollback path for every schema change
|
||||
|
||||
### Non-Functional
|
||||
- NF1: Migration runs <30s on databases with 100K entities
|
||||
- NF2: No downtime required — all changes additive (no DROP/RENAME)
|
||||
- NF3: v2 agents continue working unchanged until explicitly migrated
|
||||
- NF4: Test suite runnable in CI (<5 min)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### A. DB Schema Migration
|
||||
|
||||
#### Migration 000037: v3 memory + evolution tables
|
||||
|
||||
```sql
|
||||
-- 000037_v3_memory_evolution.up.sql
|
||||
|
||||
-- Episodic summaries (Tier 2 memory)
|
||||
CREATE TABLE episodic_summaries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
session_key TEXT NOT NULL,
|
||||
|
||||
summary TEXT NOT NULL, -- LLM-generated session summary
|
||||
key_topics TEXT[] DEFAULT '{}', -- extracted topic keywords
|
||||
embedding vector(1536), -- for similarity search
|
||||
source_type TEXT NOT NULL DEFAULT 'session', -- 'session', 'v2_daily', 'manual'
|
||||
source_id TEXT, -- session key or v2 file path (dedup guard)
|
||||
turn_count INT NOT NULL DEFAULT 0, -- number of turns summarized
|
||||
token_count INT NOT NULL DEFAULT 0, -- estimated tokens in original
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ -- NULL = no expiry. TTL configurable per agent.
|
||||
);
|
||||
|
||||
CREATE INDEX idx_episodic_agent_user ON episodic_summaries(agent_id, user_id);
|
||||
CREATE INDEX idx_episodic_tenant ON episodic_summaries(tenant_id);
|
||||
CREATE INDEX idx_episodic_source ON episodic_summaries(agent_id, source_id);
|
||||
CREATE INDEX idx_episodic_tsv ON episodic_summaries USING GIN(to_tsvector('simple', summary));
|
||||
CREATE INDEX idx_episodic_vec ON episodic_summaries USING hnsw(embedding vector_cosine_ops);
|
||||
CREATE INDEX idx_episodic_expires ON episodic_summaries(expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
-- Evolution metrics (Stage 1 self-evolution)
|
||||
CREATE TABLE agent_evolution_metrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
session_key TEXT NOT NULL,
|
||||
|
||||
metric_type TEXT NOT NULL, -- 'retrieval', 'tool', 'feedback'
|
||||
metric_key TEXT NOT NULL, -- tool name, retrieval source, signal type
|
||||
value JSONB NOT NULL, -- metric-specific payload
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_evo_metrics_agent_type ON agent_evolution_metrics(agent_id, metric_type);
|
||||
CREATE INDEX idx_evo_metrics_created ON agent_evolution_metrics(created_at);
|
||||
CREATE INDEX idx_evo_metrics_tenant ON agent_evolution_metrics(tenant_id);
|
||||
|
||||
-- Evolution suggestions (Stage 2 self-evolution)
|
||||
CREATE TABLE agent_evolution_suggestions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
|
||||
suggestion_type TEXT NOT NULL, -- 'threshold', 'tool_order', 'skill_add', 'memory_prune'
|
||||
suggestion TEXT NOT NULL,
|
||||
rationale TEXT NOT NULL,
|
||||
parameters JSONB, -- machine-readable for auto-apply
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- 'pending','approved','rejected','applied','rolled_back'
|
||||
reviewed_by TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_evo_suggestions_agent ON agent_evolution_suggestions(agent_id, status);
|
||||
CREATE INDEX idx_evo_suggestions_tenant ON agent_evolution_suggestions(tenant_id);
|
||||
```
|
||||
|
||||
**Down migration:**
|
||||
```sql
|
||||
-- 000037_v3_memory_evolution.down.sql
|
||||
DROP TABLE IF EXISTS agent_evolution_suggestions;
|
||||
DROP TABLE IF EXISTS agent_evolution_metrics;
|
||||
DROP TABLE IF EXISTS episodic_summaries;
|
||||
```
|
||||
|
||||
#### Migration 000038: KG temporal columns
|
||||
|
||||
```sql
|
||||
-- 000038_kg_temporal_columns.up.sql
|
||||
|
||||
-- Add temporal validity windows to KG entities and relations
|
||||
ALTER TABLE kg_entities ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE kg_entities ADD COLUMN IF NOT EXISTS valid_until TIMESTAMPTZ; -- NULL = current/valid
|
||||
|
||||
ALTER TABLE kg_relations ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE kg_relations ADD COLUMN IF NOT EXISTS valid_until TIMESTAMPTZ;
|
||||
|
||||
-- Index for temporal queries: "current facts" (most common query)
|
||||
CREATE INDEX idx_kg_entities_current ON kg_entities(agent_id, user_id)
|
||||
WHERE valid_until IS NULL;
|
||||
|
||||
-- Index for historical queries: "facts valid at time T"
|
||||
CREATE INDEX idx_kg_entities_temporal ON kg_entities(agent_id, user_id, valid_from, valid_until);
|
||||
|
||||
CREATE INDEX idx_kg_relations_current ON kg_relations(agent_id, user_id)
|
||||
WHERE valid_until IS NULL;
|
||||
|
||||
CREATE INDEX idx_kg_relations_temporal ON kg_relations(agent_id, user_id, valid_from, valid_until);
|
||||
```
|
||||
|
||||
**Down migration:**
|
||||
```sql
|
||||
-- 000038_kg_temporal_columns.down.sql
|
||||
DROP INDEX IF EXISTS idx_kg_relations_temporal;
|
||||
DROP INDEX IF EXISTS idx_kg_relations_current;
|
||||
DROP INDEX IF EXISTS idx_kg_entities_temporal;
|
||||
DROP INDEX IF EXISTS idx_kg_entities_current;
|
||||
|
||||
ALTER TABLE kg_relations DROP COLUMN IF EXISTS valid_until;
|
||||
ALTER TABLE kg_relations DROP COLUMN IF EXISTS valid_from;
|
||||
ALTER TABLE kg_entities DROP COLUMN IF EXISTS valid_until;
|
||||
ALTER TABLE kg_entities DROP COLUMN IF EXISTS valid_from;
|
||||
```
|
||||
|
||||
#### RequiredSchemaVersion Bump
|
||||
|
||||
```go
|
||||
// internal/upgrade/version.go
|
||||
const RequiredSchemaVersion uint = 38
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. Data Migration Strategy (Dual-Mode)
|
||||
|
||||
#### Principle: Lazy Migration on First Access
|
||||
|
||||
V3 reads v2 format on first access, auto-migrates in background. No bulk migration script. No downtime.
|
||||
|
||||
#### V2 Memory Files -> Episodic Summaries
|
||||
|
||||
```
|
||||
Trigger: Agent with v3_memory_enabled=true, first session start
|
||||
Flow:
|
||||
1. Check if agent has unmigrated v2 daily files:
|
||||
- List files matching memory/YYYY-MM-DD.md in workspace
|
||||
- Check episodic_summaries for source_id matching file path
|
||||
- If no matching episodic record: file is unmigrated
|
||||
2. For each unmigrated file:
|
||||
a. Read file content
|
||||
b. Insert episodic_summaries row:
|
||||
- summary: file content (already summarized by v2 flush)
|
||||
- source_type: "v2_daily"
|
||||
- source_id: file path (dedup guard)
|
||||
- key_topics: extract from content (simple keyword extraction)
|
||||
- turn_count: 0 (unknown for v2)
|
||||
- token_count: len(content) / 4 (estimate)
|
||||
c. Generate embedding in background (non-blocking)
|
||||
3. Mark migration complete in agent settings: v2_memory_migrated=true
|
||||
```
|
||||
|
||||
#### V2 KG Entities -> Temporal
|
||||
|
||||
```
|
||||
Trigger: Migration 000038 runs (ALTER TABLE with defaults)
|
||||
Flow:
|
||||
1. ALTER TABLE adds valid_from DEFAULT NOW() — new rows get current timestamp
|
||||
2. Existing rows: valid_from = NOW() at migration time (not created_at)
|
||||
3. Optional backfill (non-blocking, cron job):
|
||||
UPDATE kg_entities SET valid_from = created_at WHERE valid_from > created_at + INTERVAL '1 second';
|
||||
UPDATE kg_relations SET valid_from = created_at WHERE valid_from > created_at + INTERVAL '1 second';
|
||||
4. valid_until remains NULL for all existing entities (all considered "current")
|
||||
```
|
||||
|
||||
> **Decision:** Default valid_from to NOW() at migration time, not created_at. Backfill is optional — historical accuracy is nice-to-have, not required. Existing entities are "currently valid" regardless of when they were created.
|
||||
|
||||
#### V2 Sessions -> V3 Sessions
|
||||
|
||||
No migration needed. V3 pipeline operates on same session data. Sessions use same `sessions` table, same `SessionData` struct. V3 adds pipeline stages that wrap existing behavior.
|
||||
|
||||
#### Feature Flags
|
||||
|
||||
All stored in existing `agents.settings` JSONB column:
|
||||
|
||||
```json
|
||||
{
|
||||
"v3_pipeline_enabled": false,
|
||||
"v3_memory_enabled": false,
|
||||
"v3_retrieval_enabled": false,
|
||||
"self_evolution_metrics": false,
|
||||
"self_evolution_suggestions": false,
|
||||
"self_evolution_auto_adapt": false,
|
||||
"v2_memory_migrated": false,
|
||||
"retrieval_threshold": 0.3,
|
||||
"dedup_auto_merge_threshold": 0.98,
|
||||
"dedup_flag_threshold": 0.90,
|
||||
"orchestration_mode": "spawn"
|
||||
}
|
||||
```
|
||||
|
||||
**Rollout plan:**
|
||||
1. Deploy v3 binary with all flags false (default) — no behavior change
|
||||
2. Enable v3_memory_enabled per agent — triggers lazy v2 file migration
|
||||
3. Enable v3_pipeline_enabled per agent — switches to Stage-based pipeline
|
||||
4. Enable v3_retrieval_enabled per agent — activates L0 auto-inject
|
||||
5. Enable self_evolution_metrics per agent — starts recording
|
||||
6. Enable at tenant level for bulk rollout
|
||||
|
||||
---
|
||||
|
||||
### C. Code Migration Plan — Affected Files Inventory
|
||||
|
||||
#### Module 1: Foundation (Phase 1)
|
||||
| File | Change Type | Dependency |
|
||||
|------|-------------|------------|
|
||||
| `internal/agent/loop_types.go` | MODIFY: split RunState into substates | None |
|
||||
| `internal/agent/loop_compact.go` | MODIFY: replace chars/4 with TokenCounter | TokenCounter |
|
||||
| `internal/agent/pruning.go` | MODIFY: use TokenCounter for thresholds | TokenCounter |
|
||||
| `internal/tools/context_keys.go` | MODIFY: replace dual workspace keys with WorkspaceContext | None |
|
||||
| `internal/tools/workspace_resolver.go` | MODIFY: return WorkspaceContext instead of string | None |
|
||||
| `internal/tools/workspace_dir.go` | MODIFY: use WorkspaceContext | WorkspaceContext |
|
||||
| `internal/bus/types.go` | MODIFY: add new event topics + typed payloads | None |
|
||||
| `internal/bus/bus.go` | No change (existing Broadcast mechanism sufficient) | None |
|
||||
| `internal/providers/types.go` | MODIFY: add ProviderCapabilities struct | None |
|
||||
| `internal/providers/anthropic.go` | MODIFY: implement ProviderAdapter interface | ProviderAdapter |
|
||||
| `internal/providers/openai.go` | MODIFY: implement ProviderAdapter interface | ProviderAdapter |
|
||||
| `internal/providers/dashscope.go` | MODIFY: implement ProviderAdapter interface | ProviderAdapter |
|
||||
| `internal/providers/codex.go` | MODIFY: implement ProviderAdapter interface | ProviderAdapter |
|
||||
| `internal/providers/claude_cli.go` | MODIFY: implement ProviderAdapter interface | ProviderAdapter |
|
||||
| `internal/providers/acp_provider.go` | MODIFY: implement ProviderAdapter interface | ProviderAdapter |
|
||||
|
||||
**New files:**
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/agent/token_counter.go` | TokenCounter interface + tiktoken impl |
|
||||
| `internal/tools/workspace_context.go` | WorkspaceContext struct + helpers |
|
||||
| `internal/providers/adapter.go` | ProviderAdapter interface |
|
||||
| `internal/providers/capabilities.go` | ProviderCapabilities struct |
|
||||
|
||||
#### Module 2: Pipeline (Phase 2)
|
||||
| File | Change Type | Dependency |
|
||||
|------|-------------|------------|
|
||||
| `internal/agent/loop.go` | MAJOR: refactor into pipeline stages | Phase 1 foundation |
|
||||
| `internal/agent/loop_run.go` | MODIFY: pipeline entry point | Pipeline stages |
|
||||
| `internal/agent/loop_context.go` | MODIFY: becomes ContextStage | WorkspaceContext |
|
||||
| `internal/agent/loop_history.go` | MODIFY: becomes part of ContextStage | PromptBuilder |
|
||||
| `internal/agent/loop_tools.go` | MODIFY: becomes ToolStage | ToolRegistry |
|
||||
| `internal/agent/toolloop.go` | MODIFY: integrated into ToolStage | ToolStage |
|
||||
| `internal/agent/loop_compact.go` | MODIFY: becomes PruneStage | TokenCounter |
|
||||
| `internal/agent/loop_finalize.go` | MODIFY: becomes FinalizeStage | EventBus |
|
||||
| `internal/agent/loop_tracing.go` | MODIFY: per-stage tracing | Pipeline stages |
|
||||
|
||||
**New files:**
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/agent/pipeline.go` | Pipeline, Stage interface, runner |
|
||||
| `internal/agent/stage_context.go` | ContextStage |
|
||||
| `internal/agent/stage_think.go` | ThinkStage (LLM call) |
|
||||
| `internal/agent/stage_prune.go` | PruneStage (token counting + pruning) |
|
||||
| `internal/agent/stage_tool.go` | ToolStage (tool execution) |
|
||||
| `internal/agent/stage_observe.go` | ObserveStage (loop detection, metrics) |
|
||||
| `internal/agent/stage_checkpoint.go` | CheckpointStage (session save) |
|
||||
|
||||
#### Module 3: Memory & KG (Phase 3)
|
||||
| File | Change Type | Dependency |
|
||||
|------|-------------|------------|
|
||||
| `internal/memory/embeddings.go` | MODIFY: integrate with episodic store | EpisodicStore |
|
||||
| `internal/knowledgegraph/extractor.go` | MODIFY: async extraction via events | EventBus |
|
||||
| `internal/knowledgegraph/extractor_prompt.go` | No change | |
|
||||
| `internal/store/memory_store.go` | MODIFY: add EpisodicSummaryStore interface | None |
|
||||
| `internal/store/knowledge_graph_store.go` | MODIFY: add temporal query methods | None |
|
||||
| `internal/store/pg/memory_docs.go` | No change (v2 compat) | |
|
||||
| `internal/store/pg/memory_search.go` | MODIFY: integrate episodic search | EpisodicStore |
|
||||
| `internal/store/pg/knowledge_graph.go` | MODIFY: temporal WHERE clauses | KG temporal cols |
|
||||
| `internal/store/pg/knowledge_graph_dedup.go` | MODIFY: configurable thresholds | Agent settings |
|
||||
| `internal/store/pg/knowledge_graph_relations.go` | MODIFY: temporal WHERE clauses | KG temporal cols |
|
||||
| `internal/store/pg/knowledge_graph_traversal.go` | MODIFY: temporal filter in traversal | KG temporal cols |
|
||||
| `internal/store/pg/knowledge_graph_embedding.go` | No change | |
|
||||
| `internal/agent/memoryflush.go` | MODIFY: v3 path uses event-driven consolidation | EventBus |
|
||||
| `internal/agent/extractive_memory.go` | No change (fallback for v2 mode) | |
|
||||
|
||||
**New files:**
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/store/episodic_store.go` | EpisodicSummaryStore interface |
|
||||
| `internal/store/pg/episodic_summaries.go` | PG implementation |
|
||||
| `internal/agent/consolidation_worker.go` | Event-driven session -> episodic -> semantic |
|
||||
| `internal/agent/memory_migrator.go` | Lazy v2 daily files -> episodic migration |
|
||||
|
||||
#### Module 4: System Integration (Phase 4)
|
||||
| File | Change Type | Dependency |
|
||||
|------|-------------|------------|
|
||||
| `internal/agent/systemprompt.go` | MAJOR: replace with PromptBuilder | PromptConfig types |
|
||||
| `internal/agent/systemprompt_sections.go` | REMOVE: logic moves to template | PromptBuilder |
|
||||
| `internal/tools/types.go` | MODIFY: add Metadata() to Tool interface | None |
|
||||
| `internal/tools/registry.go` | MODIFY: store ToolMetadata | ToolMetadata |
|
||||
| `internal/tools/policy.go` | MODIFY: capability rules + role param | ToolCapability |
|
||||
| `internal/tools/filesystem.go` | MODIFY: WorkspaceContextFromCtx() | WorkspaceContext |
|
||||
| `internal/tools/filesystem_write.go` | MODIFY: WorkspaceContextFromCtx() | WorkspaceContext |
|
||||
| `internal/tools/filesystem_list.go` | MODIFY: WorkspaceContextFromCtx() | WorkspaceContext |
|
||||
| `internal/tools/shell.go` | MODIFY: WorkspaceContextFromCtx() | WorkspaceContext |
|
||||
| `internal/tools/edit.go` | MODIFY: WorkspaceContextFromCtx() | WorkspaceContext |
|
||||
| `internal/tools/memory.go` | MODIFY: add depth parameter | L0/L1/L2 |
|
||||
| `internal/tools/knowledge_graph.go` | MODIFY: add as_of temporal param | KG temporal |
|
||||
| `internal/tools/skill_search.go` | MODIFY: hybrid search integration | HybridSearcher |
|
||||
| `internal/skills/loader.go` | MODIFY: ACL-aware BuildSummary() | SkillAccessStore |
|
||||
| `internal/skills/search.go` | MODIFY: expose for fusion with embedding | None |
|
||||
|
||||
**New files:** (listed in Phase 4 design)
|
||||
|
||||
#### Module 5: Orchestration & Intelligence (Phase 5)
|
||||
| File | Change Type | Dependency |
|
||||
|------|-------------|------------|
|
||||
| `internal/tools/policy.go` | MODIFY: orchestration mode filtering | OrchestrationMode |
|
||||
| `internal/bus/types.go` | MODIFY: delegation + evolution event types | None |
|
||||
| `internal/agent/loop.go` | MODIFY: metrics recording hooks | EvolutionMetrics |
|
||||
| `cmd/gateway.go` | MODIFY: register delegate tool, event subscribers | All Phase 5 |
|
||||
|
||||
**New files:** (listed in Phase 5 design)
|
||||
|
||||
#### Breaking Changes Inventory
|
||||
|
||||
| Change | Impact | Backward Compat |
|
||||
|--------|--------|-----------------|
|
||||
| Tool.Metadata() added to interface | All tool implementations | Wrapper: `defaultMetadata(tool)` |
|
||||
| WorkspaceContext replaces dual keys | All file tools + loop_context | Single migration: grep `ToolWorkspaceFromCtx` (15 sites) |
|
||||
| PromptBuilder replaces BuildSystemPrompt() | loop_history.go, tests | Bridge: old func calls PromptBuilder.Build() |
|
||||
| Pipeline stages replace runLoop() | loop.go (804 lines) | Feature flag: v3_pipeline_enabled per agent |
|
||||
| TokenCounter replaces chars/4 | loop_compact.go, pruning.go | Fallback: chars/4 for unknown models |
|
||||
| KG temporal columns | All KG queries | Default: WHERE valid_until IS NULL (same as pre-temporal) |
|
||||
| Episodic summaries table | New (no existing code depends on it) | Fully additive |
|
||||
| Evolution metrics/suggestions | New (no existing code depends on it) | Fully additive |
|
||||
|
||||
---
|
||||
|
||||
### D. Integration Test Plan
|
||||
|
||||
#### Test Matrix: Workspace Scenarios
|
||||
|
||||
From workspace forcing report — 6 scenarios must pass:
|
||||
|
||||
| # | Scenario | ActivePath | Scope | Key Assertion |
|
||||
|---|----------|------------|-------|---------------|
|
||||
| 1 | Solo DM, isolated | `{base}/{userID}` | personal | Files scoped to user dir |
|
||||
| 2 | Solo DM, shared | `{base}` | personal | Files in shared agent dir |
|
||||
| 3 | Team dispatch (member) | `{base}/teams/{teamID}/{chatID}` | team-shared | Workspace forced to team dir |
|
||||
| 4 | Team dispatch (leader) | `{base}/teams/{teamID}/{chatID}` | team-shared | Leader sees team workspace |
|
||||
| 5 | Team inbound (member) | `{base}/teams/{teamID}/{chatID}` | team-isolated | Per-chat isolation within team |
|
||||
| 6 | Delegate workspace | delegatee workspace + shared area | delegate | Read-only exports enforced |
|
||||
|
||||
#### Memory Tier Tests
|
||||
|
||||
| Test | Input | Expected |
|
||||
|------|-------|----------|
|
||||
| Working memory (Tier 1) | 20 messages in session | All messages in context window |
|
||||
| Episodic creation | Session end event | episodic_summaries row created |
|
||||
| Episodic dedup | Same session end event replayed | No duplicate (source_id unique) |
|
||||
| Semantic extraction | Episodic with entities | KG entities upserted |
|
||||
| L0 auto-inject | User message with relevant topic | Memory section in system prompt |
|
||||
| L0 skip | "hello" message (low relevance) | No Memory section |
|
||||
| V2 migration | Agent with memory/2026-01-01.md | Episodic row with source_type=v2_daily |
|
||||
| TTL expiry | Episodic with expires_at in past | Cleaned by cron |
|
||||
|
||||
#### KG Temporal Tests
|
||||
|
||||
| Test | Input | Expected |
|
||||
|------|-------|----------|
|
||||
| New entity | UpsertEntity() | valid_from=NOW(), valid_until=NULL |
|
||||
| Supersede entity | Contradicting fact | Old: valid_until=NOW(). New: valid_from=NOW() |
|
||||
| Current query | SearchEntities() default | Only valid_until IS NULL entities |
|
||||
| Historical query | SearchEntities(as_of="2026-01-01") | Entities valid at that date |
|
||||
| Temporal traversal | Traverse() with temporal entities | Only current relations followed |
|
||||
| Configurable dedup | Agent with threshold 0.95 | Auto-merge at 0.95, not hardcoded 0.98 |
|
||||
|
||||
#### Pipeline Stage Tests
|
||||
|
||||
| Test | Stage | Input | Expected |
|
||||
|------|-------|-------|----------|
|
||||
| Context build | ContextStage | RunRequest + agent config | WorkspaceContext resolved, system prompt built |
|
||||
| LLM call | ThinkStage | Messages array | Provider called via adapter |
|
||||
| Token pruning | PruneStage | Messages exceeding 85% context | Older messages pruned, token count accurate |
|
||||
| Tool exec | ToolStage | Tool call from LLM | Tool executed via registry with policy check |
|
||||
| Loop detect | ObserveStage | 3x same tool call | Loop detected, force stop |
|
||||
| Session save | CheckpointStage | Completed run | Session persisted, events emitted |
|
||||
| Full pipeline | All stages | Complete request | End-to-end: request -> response -> session saved |
|
||||
|
||||
#### Provider Adapter Tests
|
||||
|
||||
| Test | Provider | Input | Expected |
|
||||
|------|----------|-------|----------|
|
||||
| Anthropic wire | Anthropic | Internal ChatRequest | Valid Anthropic API JSON |
|
||||
| OpenAI wire | OpenAI | Internal ChatRequest | Valid OpenAI API JSON |
|
||||
| DashScope no-stream-tools | DashScope | ChatRequest with tools | Streaming disabled, tools present |
|
||||
| Codex OAuth | Codex | ChatRequest | OAuth token refreshed if needed |
|
||||
| Claude CLI | ClaudeCLI | ChatRequest | Stdio command built correctly |
|
||||
| Capabilities | All providers | Capabilities() | Correct flags per provider |
|
||||
| Round-trip | All providers | ToRequest -> FromResponse | Internal format preserved |
|
||||
|
||||
#### Regression Tests
|
||||
|
||||
| Test | Assertion |
|
||||
|------|-----------|
|
||||
| V2 agent with all flags false | Identical behavior to pre-v3 binary |
|
||||
| V2 memory flush | Daily .md files still written when v3_memory_enabled=false |
|
||||
| V2 KG without temporal | Queries return same results (WHERE valid_until IS NULL matches all) |
|
||||
| V2 system prompt | PromptBuilder with v2 config produces identical output |
|
||||
| V2 tool registry | Tools without Metadata() get default metadata wrapper |
|
||||
|
||||
---
|
||||
|
||||
### E. Audit Checklist
|
||||
|
||||
#### Interface Contracts
|
||||
- [ ] All interface contracts have method signatures with ctx context.Context as first param
|
||||
- [ ] All interfaces have at least one implementation (PG store)
|
||||
- [ ] No interface has >10 methods (split if so)
|
||||
- [ ] Error returns are typed (not raw string errors)
|
||||
- [ ] Context propagation: tenant_id, agent_id, user_id carried through all store calls
|
||||
|
||||
#### DB Schemas
|
||||
- [ ] All new tables have tenant_id column with FK to tenants(id)
|
||||
- [ ] All FKs have ON DELETE CASCADE where appropriate
|
||||
- [ ] Indexes exist for: primary lookup, tenant filter, time-range queries
|
||||
- [ ] No new columns on existing tables (all config in JSONB settings)
|
||||
- [ ] Down migrations cleanly reverse up migrations
|
||||
- [ ] No PG-specific features in interface definitions (SQLite compat future)
|
||||
|
||||
#### Data Flow
|
||||
- [ ] Memory flow: user message -> auto-inject L0 -> response -> session end -> episodic -> semantic
|
||||
- [ ] KG flow: text -> extract -> upsert -> dedup -> temporal validity
|
||||
- [ ] Pipeline flow: ContextStage -> ThinkStage -> PruneStage -> ToolStage -> ObserveStage -> CheckpointStage
|
||||
- [ ] Delegation flow: delegate() -> permission check -> workspace setup -> AgentRunner -> result delivery
|
||||
- [ ] Event flow: stage completion -> EventBus.Broadcast() -> subscriber handlers
|
||||
|
||||
#### Breaking Changes
|
||||
- [ ] Tool.Metadata() — wrapper provided for existing tools
|
||||
- [ ] WorkspaceContext — single context key replaces 2 (grep all call sites)
|
||||
- [ ] BuildSystemPrompt() — bridge function wraps PromptBuilder
|
||||
- [ ] Pipeline stages — feature flag gates new behavior
|
||||
- [ ] Token counting — fallback to chars/4 for unknown models
|
||||
|
||||
#### Security Review
|
||||
- [ ] Workspace isolation: WorkspaceContext.ActivePath enforced by resolvePath()
|
||||
- [ ] Delegate read-only exports: validated against delegator's workspace boundary
|
||||
- [ ] Delegate shared area: created with restrictive permissions (0750)
|
||||
- [ ] Tenant boundary: all new tables have tenant_id, all queries filter by it
|
||||
- [ ] Metrics data: no PII stored (queries + tool names, not user content)
|
||||
- [ ] Template injection: PromptConfig data passed via {{ printf "%s" }} not {{ }}
|
||||
- [ ] Auto-adapt scope: only modifies retrieval params, not security settings
|
||||
|
||||
#### Performance Review
|
||||
- [ ] tiktoken-go: benchmark Count() for Claude models (<1ms per 1000 tokens)
|
||||
- [ ] pgvector: benchmark episodic_summaries search at 100K rows (<50ms)
|
||||
- [ ] Event bus: benchmark Broadcast throughput (10K events/sec target)
|
||||
- [ ] Template rendering: benchmark Build() (<1ms, no allocation-heavy paths)
|
||||
- [ ] L0 auto-inject: benchmark memory index query (<10ms per turn)
|
||||
- [ ] KG temporal: benchmark index scan with valid_until IS NULL filter
|
||||
- [ ] Metrics recording: verify non-blocking (goroutine, no mutex on hot path)
|
||||
|
||||
---
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Migration Files to Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `migrations/000037_v3_memory_evolution.up.sql` | episodic_summaries, agent_evolution_metrics, agent_evolution_suggestions |
|
||||
| `migrations/000037_v3_memory_evolution.down.sql` | Drop above tables |
|
||||
| `migrations/000038_kg_temporal_columns.up.sql` | ALTER kg_entities/kg_relations add valid_from/valid_until |
|
||||
| `migrations/000038_kg_temporal_columns.down.sql` | DROP added columns + indexes |
|
||||
|
||||
### Files to Modify
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/upgrade/version.go` | RequiredSchemaVersion = 38 |
|
||||
|
||||
### Full Affected File Count by Module
|
||||
|
||||
| Module | Files Modified | Files Created | Total |
|
||||
|--------|---------------|---------------|-------|
|
||||
| Foundation (Phase 1) | 15 | 4 | 19 |
|
||||
| Pipeline (Phase 2) | 9 | 7 | 16 |
|
||||
| Memory & KG (Phase 3) | 14 | 4 | 18 |
|
||||
| System Integration (Phase 4) | 15 | 9 | 24 |
|
||||
| Orchestration (Phase 5) | 4 | 10 | 14 |
|
||||
| Migration (Phase 6) | 1 | 4 | 5 |
|
||||
| **Total** | **58** | **38** | **96** |
|
||||
|
||||
---
|
||||
|
||||
## Design Steps
|
||||
|
||||
1. Write migration 000037 DDL (episodic_summaries, evolution tables)
|
||||
2. Write migration 000038 DDL (KG temporal columns)
|
||||
3. Write down migrations for both
|
||||
4. Document lazy v2 memory migration flow
|
||||
5. Document KG temporal backfill strategy
|
||||
6. List all feature flags with defaults
|
||||
7. Inventory all affected files per module (complete)
|
||||
8. Document breaking changes with migration path
|
||||
9. Write workspace scenario test matrix
|
||||
10. Write memory tier test scenarios
|
||||
11. Write KG temporal test scenarios
|
||||
12. Write pipeline stage test scenarios
|
||||
13. Write provider adapter test matrix
|
||||
14. Write regression test list
|
||||
15. Complete audit checklist
|
||||
|
||||
---
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] A1: Migration 000037 DDL (3 new tables)
|
||||
- [ ] A2: Migration 000038 DDL (KG temporal ALTER)
|
||||
- [ ] A3: Down migrations for both
|
||||
- [ ] A4: RequiredSchemaVersion bump to 38
|
||||
- [ ] B1: V2 memory -> episodic lazy migration design
|
||||
- [ ] B2: KG temporal backfill strategy
|
||||
- [ ] B3: Feature flags catalog with defaults
|
||||
- [ ] B4: Session v2->v3 compatibility notes
|
||||
- [ ] C1: Affected files inventory per module (complete)
|
||||
- [ ] C2: Code change dependency order
|
||||
- [ ] C3: Breaking changes list with backward-compat path
|
||||
- [ ] D1: Workspace scenario test matrix (6 scenarios)
|
||||
- [ ] D2: Memory tier tests (8 scenarios)
|
||||
- [ ] D3: KG temporal tests (6 scenarios)
|
||||
- [ ] D4: Pipeline stage tests (7 scenarios)
|
||||
- [ ] D5: Provider adapter tests (7 scenarios)
|
||||
- [ ] D6: Regression tests (5 scenarios)
|
||||
- [ ] E1: Interface contracts audit
|
||||
- [ ] E2: DB schema audit
|
||||
- [ ] E3: Data flow audit
|
||||
- [ ] E4: Breaking changes audit
|
||||
- [ ] E5: Security review
|
||||
- [ ] E6: Performance review
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Both migrations run cleanly on empty DB and on DB with existing v2 data
|
||||
2. Down migrations fully reverse up migrations (no orphaned objects)
|
||||
3. RequiredSchemaVersion matches highest migration number
|
||||
4. All 6 workspace scenarios pass integration tests
|
||||
5. V2 agents with all flags false behave identically to pre-v3
|
||||
6. Every breaking change has documented backward-compat path
|
||||
7. Audit checklist 100% complete before implementation begins
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Migration fails on large KG tables | MEDIUM | ALTER TABLE ADD COLUMN with DEFAULT is metadata-only in PG12+ (instant). No table rewrite. |
|
||||
| V2 memory files not found (path differences) | LOW | Lazy migration lists actual files in workspace. Files missing = nothing to migrate. |
|
||||
| Feature flag combinatorial explosion | MEDIUM | 6 boolean flags = 64 combos. Test key combos only: all-off (v2), all-on (v3), individual toggles. |
|
||||
| 96 affected files scope | HIGH | Implementation phased by module. Each module independently deployable behind feature flags. |
|
||||
| Episodic summaries table grows large | MEDIUM | expires_at column + cleanup cron. Index on expires_at for efficient deletion. |
|
||||
| Temporal KG backfill slow | LOW | Backfill is optional, runs as cron in background. No downtime. Default valid_from=NOW() sufficient. |
|
||||
| SQLite compat deferred too long | MEDIUM | Interfaces designed PG-agnostic. SQLite impl can be added later without interface changes. |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### Per-Migration Rollback
|
||||
```bash
|
||||
./goclaw migrate down 2 # reverts 000038, then 000037
|
||||
```
|
||||
|
||||
### Per-Feature Rollback
|
||||
Set feature flag to false in agent settings:
|
||||
```json
|
||||
{ "v3_pipeline_enabled": false, "v3_memory_enabled": false }
|
||||
```
|
||||
Agent immediately reverts to v2 behavior. No code rollback needed.
|
||||
|
||||
### Full v3 Rollback
|
||||
1. Disable all v3 feature flags for all agents
|
||||
2. Run `migrate down 2` to remove v3 tables
|
||||
3. Deploy v2 binary
|
||||
4. v2 memory files, KG entities, sessions — all intact (v3 tables were additive)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Migration safety:** All changes additive (CREATE TABLE, ALTER ADD COLUMN). No DROP, RENAME, or data modification in up migration. Down migration only drops v3-added objects.
|
||||
- **Feature flag isolation:** v3 code paths gated by flags. Flag false = v2 code path. No mixed state.
|
||||
- **Tenant isolation in new tables:** All 3 new tables have tenant_id FK. All queries must include tenant_id filter. Store implementations enforce via context.
|
||||
- **Evolution metrics cleanup:** Cron job with configurable TTL (default 90 days). Prevents unbounded growth.
|
||||
- **Temporal KG queries:** Default WHERE valid_until IS NULL produces same results as pre-temporal. No data exposure from temporal columns.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Dependency Order
|
||||
|
||||
```
|
||||
Foundation (can start immediately, no dependencies):
|
||||
1. TokenCounter (internal/agent/token_counter.go)
|
||||
2. WorkspaceContext (internal/tools/workspace_context.go)
|
||||
3. EventBus topics (internal/bus/types.go)
|
||||
4. ProviderAdapter interface (internal/providers/adapter.go)
|
||||
|
||||
Core (depends on Foundation):
|
||||
5. Pipeline stages (internal/agent/pipeline.go + stage_*.go)
|
||||
6. PromptBuilder + templates (internal/agent/prompt_builder.go)
|
||||
7. Tool Metadata + enhanced registry (internal/tools/capability.go)
|
||||
|
||||
Memory & KG (depends on Foundation + migrations):
|
||||
8. DB migrations 000037 + 000038
|
||||
9. EpisodicSummaryStore (internal/store/episodic_store.go)
|
||||
10. KG temporal queries (modify existing pg/ files)
|
||||
11. Consolidation worker (internal/agent/consolidation_worker.go)
|
||||
12. L0 retriever (internal/agent/retriever.go)
|
||||
|
||||
Integration (depends on Core + Memory):
|
||||
13. Hybrid skill searcher (internal/skills/hybrid_searcher.go)
|
||||
14. Tool policy capability rules (internal/tools/policy.go)
|
||||
15. Provider adapters (all 6 providers)
|
||||
|
||||
Orchestration (depends on Integration):
|
||||
16. DelegateTool (internal/tools/delegate_tool.go)
|
||||
17. Orchestration mode config (internal/agent/orchestration_mode.go)
|
||||
18. Evolution metrics (internal/store/pg/evolution_metrics.go)
|
||||
19. Evolution suggestions (internal/agent/evolution_suggestion_engine.go)
|
||||
|
||||
Testing (depends on all above):
|
||||
20. Integration tests (tests/integration/v3_*.go)
|
||||
21. Regression tests (tests/integration/v2_compat_*.go)
|
||||
```
|
||||
|
||||
Items 1-4 are independent and can be built in parallel.
|
||||
Items 5-7 are independent of each other but depend on 1-4.
|
||||
Items 8-12 are sequential (migrations first, then stores, then workers).
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
title: "GoClaw v3 Core Architecture Design"
|
||||
description: "Comprehensive design plan for v3 core redesign — 18 modules covering memory, KG, agent loop, providers, workspace, orchestration"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: 40h
|
||||
branch: dev-v3
|
||||
tags: [refactor, backend, architecture, design]
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: 2026-04-06
|
||||
---
|
||||
|
||||
# GoClaw v3 Core Architecture Design
|
||||
|
||||
## Overview
|
||||
|
||||
Design-first plan for comprehensive v3 core redesign. Produces interface contracts, DB schemas, data flow diagrams, and dependency graphs — NOT implementation code. Implementation plans created per-phase after design audit.
|
||||
|
||||
**Brainstorm report:** `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md`
|
||||
|
||||
## Cross-Plan Dependencies
|
||||
|
||||
None. This plan blocks future implementation plans (to be created after design audit).
|
||||
|
||||
## Key Decisions (from brainstorm)
|
||||
|
||||
| # | Module | Decision |
|
||||
|---|--------|----------|
|
||||
| 1 | Loop | Pipeline stages (pluggable interfaces) |
|
||||
| 2 | Memory | 3-tier (working/episodic/semantic) |
|
||||
| 3 | Context | L0/L1/L2 progressive loading |
|
||||
| 4 | KG | Temporal validity windows |
|
||||
| 5 | Token counting | tiktoken-go accurate |
|
||||
| 6 | Consolidation | Async event-driven |
|
||||
| 7 | Retrieval | Smart auto-inject L0 + on-demand |
|
||||
| 8 | Workspace | Unified WorkspaceContext |
|
||||
| 9 | System prompt | Template-based |
|
||||
| 10 | Providers | Capability + unified wire + plugin |
|
||||
| 11 | Self-evolution | Progressive (metrics→suggest→auto) |
|
||||
| 12 | Orchestration | Layered: delegate base + team optional |
|
||||
| 13 | Tools | Redesign (registry, policy, workspace-aware) |
|
||||
| 14 | Events | Redesign (structured, typed) |
|
||||
| 15 | Skills | Semantic search + ACL + deps |
|
||||
| 16 | Context files | DB + better abstraction |
|
||||
| 17 | Migration | Dual-mode v2→v3 |
|
||||
| 18 | SQLite | Deferred |
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status | Effort |
|
||||
|-------|------|--------|--------|
|
||||
| 1 | [Foundation Interfaces](./phase-01-foundation-interfaces.md) | **Complete** | 8h |
|
||||
| 2 | [Pipeline Loop Design](./phase-02-pipeline-loop-design.md) | **Complete** | 8h |
|
||||
| 3 | [Memory & KG Design](./phase-03-memory-kg-design.md) | **Complete** | 8h |
|
||||
| 4 | [System Integration Design](./phase-04-system-integration-design.md) | **Complete** | 6h |
|
||||
| 5 | [Orchestration & Intelligence Design](./phase-05-orchestration-intelligence-design.md) | **Complete** | 6h |
|
||||
| 6 | [Migration & Audit](./phase-06-migration-audit.md) | **Complete** | 4h |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 1: Foundation ─────────────────────────┐
|
||||
(TokenCounter, WorkspaceContext, │
|
||||
EventBus, ProviderAdapter) │
|
||||
│ │
|
||||
▼ │
|
||||
Phase 2: Pipeline Loop ──────────┐ │
|
||||
(Stage interfaces, RunState) │ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Phase 3: Memory & KG Phase 4: System Integration
|
||||
(3-tier, temporal, (Template prompt, tool
|
||||
consolidation, L0/L1/L2) registry, skills, retrieval)
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
▼
|
||||
Phase 5: Orchestration & Intelligence
|
||||
(Delegate, self-evolution)
|
||||
│
|
||||
▼
|
||||
Phase 6: Migration & Audit
|
||||
(v2→v3 dual-mode, test plan)
|
||||
```
|
||||
|
||||
## Deliverables Per Phase
|
||||
|
||||
Each phase produces:
|
||||
- **Interface contracts** — Go interface definitions with method signatures
|
||||
- **DB schemas** — SQL DDL for new/modified tables
|
||||
- **Data flow diagrams** — Textual flow descriptions
|
||||
- **Config schemas** — JSON/YAML configuration structures
|
||||
- **Dependency map** — Which existing files are affected
|
||||
|
||||
## References
|
||||
|
||||
- Brainstorm: `plans/reports/brainstorm-260406-1059-goclaw-v3-core-redesign.md`
|
||||
- Research: `plans/reports/researcher-260406-1059-memory-context-kg-research.md`
|
||||
- Architecture: `plans/reports/Explore-260406-thorough-v3-architecture.md`
|
||||
- Loop deep dive: `plans/reports/Explore-260406-GoClaw-Agent-Loop-v3-Deep-Dive.md`
|
||||
- Workspace forcing: `plans/reports/Explore-260406-workspace-forcing.md`
|
||||
Reference in new issue
Block a user