mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(v3): core architecture redesign — pipeline, memory, vault, evolution, providers, orchestration (#790)
* feat(v3): add core interface contracts and migration for v3 redesign
Foundation interfaces: TokenCounter, WorkspaceContext, DomainEventBus,
ProviderAdapter/Capabilities. Pipeline: Stage, RunState, MessageBuffer,
substates, Pipeline orchestrator. Memory: EpisodicStore, AutoInjector,
KG temporal extensions, consolidation workers. System integration:
PromptConfig, ToolCapability, Retriever. Orchestration: OrchestrationMode,
EvolutionMetrics/SuggestionStore. Migration 000037: episodic_summaries,
evolution tables, KG temporal columns. Schema version 36→37.
* refactor(plans): mark all v3 design phases complete with file references
* fix(v3): address code review findings on design contracts
- C1: add missing l0_abstract column to episodic_summaries migration
- C2: align EpisodicSummary ID/TenantID/AgentID to uuid.UUID
- H1: document tenant_id scoping requirement on EpisodicStore
- H2: add UNIQUE constraint on (agent_id, user_id, source_id) for dedup
- H4: clarify ProviderAdapter vs Provider relationship in doc
- M3: set state.ExitCode on BreakLoop/AbortRun in pipeline
- M6: store full PipelineConfig in Pipeline struct
- Edge: add WHERE embedding IS NOT NULL on HNSW index
* fix(v3): second-pass review fixes
- H1: use context.WithoutCancel for finalize + set ExitCode on ctx cancel
- H2: use utf8.RuneCountInString consistently in FallbackCounter
- H3: longest-prefix-match in ModelContextWindow (prevents wrong tokenizer)
- H4: return unsubscribe cleanup func from consolidation.Register
* feat(v3): implement DomainEventBus with worker pool, dedup, and retry
Worker pool processes events from buffered channel. SourceID-based dedup
prevents duplicate processing. Exponential backoff retry on handler error.
Panic recovery per handler. Graceful shutdown via Drain(). 8/8 tests pass
with race detector.
* feat(v3): implement ProviderAdapter for Anthropic, OpenAI, DashScope, Codex
Add CapabilitiesAware to all 6 providers. Create ProviderAdapter
implementations that delegate to existing buildRequestBody/parseResponse
for DRY. ClaudeCLI and ACP get capabilities only (subprocess transport).
DashScope wraps OpenAI adapter with StreamWithTools=false override.
* feat(v3): implement WorkspaceContext Resolver for 6 scenarios
Stateless resolver produces immutable WorkspaceContext at run start.
Handles personal/group/predefined/team-shared/team-isolated/delegation.
Wired into loop_context.go behind v3PipelineEnabled flag (additive,
v2 path unchanged). Includes delegation path boundary check,
master tenant bypass, and tenant slug path composition.
* feat(v3): implement tiktoken TokenCounter with BPE encoding + cache
Adds tiktoken-go for accurate cl100k_base/o200k_base token counting.
Per-message FNV-1a hash cache avoids re-encoding unchanged history.
Falls back to rune/3 heuristic for unknown models. NewTokenCounter
factory selects implementation at build time.
* feat(v3): promote 12 other_config JSONB fields to dedicated agent columns
Extract emoji, agent_description, thinking_level, max_tokens,
self_evolve, skill_evolve, skill_nudge_interval, reasoning_config,
workspace_sharing, chatgpt_oauth_routing, shell_deny_groups, and
kg_dedup_config from the catch-all other_config JSONB into proper
columns with DB-level types and defaults.
- Migration: PG (000037) + SQLite (schema v6→7) with backfill
- Go: AgentData struct + simplified Parse* methods
- Store: SELECT/INSERT/scan updated for both PG and SQLite
- Gateway: create/update handlers accept promoted fields
- HTTP: export/import with legacy backward compat
- Web UI: all 15 frontend files read/write from top level
* feat(v3): implement Knowledge Vault with unified search, wikilinks, and FS sync
Migration 000038 adds vault_documents (FTS+pgvector), vault_links, vault_versions
tables. VaultStore interface with PG implementation for document CRUD, hybrid
FTS+vector search, and bidirectional link management. All queries enforce
tenant_id isolation including JOIN-based scoping on link operations.
FS sync layer: SHA-256 content hashing, VaultInterceptor hooks into write_file/
read_file for auto-registration and lazy sync, fsnotify watcher with 500ms
debounce. Wikilink engine parses [[target]] syntax, resolves targets via
3-step strategy, and maintains vault_links on write.
VaultSearchService fans out queries across vault, episodic, and KG stores in
parallel with per-source score normalization and weighted merge. AutoInjector
and Retriever implementations for pipeline integration.
Three agent tools: vault_search (unified discovery), vault_link (explicit
linking), vault_backlinks (dependency tracing). Feature-flagged via
v3_vault_enabled agent setting.
* feat(v3): wire vault into gateway startup + add unit tests
Wire VaultStore embedding provider, VaultSearchService, VaultInterceptor
on read/write tools, and register vault_search/vault_link/vault_backlinks
tools in gateway_vault_wiring.go. All wiring gated by stores.Vault != nil.
Add 28 unit tests for ContentHash, ContentHashFile, and ExtractWikilinks
covering edge cases, unicode, display text, context windows, and offsets.
* feat(v3): implement stage-based pipeline loop with 8 pluggable stages
Decompose monolithic agent loop into internal/pipeline/ package:
- 6 stages: Context, Think, Prune+MemoryFlush, Tool, Observe+Checkpoint, Finalize
- Foundation types: Stage interface, RunState with 7 typed substates, MessageBuffer
- Pipeline orchestrator with setup/iteration/finalize 3-phase execution
- Callback-based PipelineDeps avoids circular import with agent package
- Feature-flagged via v3PipelineEnabled in Loop.Run()
- All 7 exit conditions preserved (no tools, max iter, truncation, loop kill,
read-only streak, tool budget, ctx cancel)
* feat(v3): wire pipeline callbacks to Loop methods + add 71 unit tests
Wire 15 of 17 PipelineDeps callbacks from Loop methods via closures:
- Context: LoadContextFiles, BuildMessages, EnrichMedia, InjectReminders
- Think: BuildFilteredTools, CallLLM (stream/sync)
- Prune: PruneMessages, CompactMessages
- Memory: RunMemoryFlush
- Finalize: SanitizeContent, FlushMessages, UpdateMetadata, BootstrapCleanup, MaybeSummarize
- Remaining: ExecuteToolCall, CheckReadOnly (deep loop.go integration)
Add comprehensive test suite (71 tests, all passing with -race):
- MessageBuffer: 10 tests (append, flush, replace, counts)
- Pipeline.Run: 14 tests (3-phase flow, exit conditions, ctx cancel)
- Stage tests: 47 tests (ThinkStage nudges/truncation, PruneStage budget,
ToolStage parallel/exit, ObserveStage content, CheckpointStage interval,
FinalizeStage cleanup)
* feat(v3): wire remaining 2 callbacks (ExecuteToolCall, CheckReadOnly)
Complete callback wiring — 17/17 PipelineDeps callbacks now active:
- ExecuteToolCall: resolves tool name, executes via registry, processes
result via existing processToolResult with loop detection bridge
- CheckReadOnly: delegates to checkReadOnlyStreak via bridge runState
- Bridge runState shares loop detection state between pipeline and agent
* fix(v3): eliminate data race in tool execution + capture injected messages
- Remove parallel tool execution path — serialize all tool calls to avoid
data races on shared bridgeRS (loop detector, media results, deliverables)
- Loop kill checked after each tool (mid-batch early exit)
- BuildFilteredTools: capture and append injected tool-awareness messages
- Rename test to reflect sequential execution
* feat(v3): wire ResolveWorkspace, safe parallel tools, ContextStage tests
- Wire ResolveWorkspace callback via workspace.NewResolver() with
ResolveParams from Loop fields (no longer a nil stub)
- Re-add safe parallel tool execution: split into ExecuteToolRaw
(parallel I/O) + ProcessToolResult (sequential state mutation)
with opaque rawData pass-through (no double execution)
- Add 12 unit tests for ContextStage (8) + MemoryFlushStage (3)
- Split tool callbacks to loop_pipeline_tool_callbacks.go (under 200 lines)
- Capture buildFilteredTools injected messages
* feat(v3): add episodic memory store + temporal KG columns
Phase 1 — Episodic Store:
- Migration 000039: episodic_summaries table with pgvector, FTS, L0 abstracts
- EpisodicStore PG impl: CRUD, hybrid FTS+vector search, ExistsBySourceID,
PruneExpired. Idempotent via source_id UNIQUE constraint.
Phase 2 — Temporal KG:
- Migration 000040: valid_from/valid_until on kg_entities + kg_relations,
partial indexes for current-facts queries, epoch→timestamptz backfill
- ListEntitiesTemporal: current-only, point-in-time, or include-expired modes
- SupersedeEntity: atomic expire-old + insert-new in single transaction
Schema version bumped to 40.
* fix(v3): review fixes for episodic store + temporal KG
- C1: Fix column name mismatch turn_count vs message_count in Go SQL
- C2: Remove redundant migration 000040 (000037 already adds temporal KG columns)
- H1: Use time.Time not int64 for TIMESTAMPTZ columns in SupersedeEntity
- H2: Add tenant_id scoping to Get/Delete for tenant isolation
- M2: Fix scanEntityTemporal to convert TIMESTAMPTZ→UnixMilli correctly
- L1: Remove unused uuid import from episodic_search.go
- Schema version corrected to 39 (only 000039 is new)
* feat(v3): implement consolidation pipeline with 3 event-driven workers
Event chain: session.completed → EpisodicWorker → episodic.created →
SemanticWorker → entity.upserted → DedupWorker
- EpisodicWorker: reuses compaction summary or calls LLM, generates L0
abstract (extractive), idempotent via source_id check
- SemanticWorker: extracts KG facts from episodic summary via existing
Extractor, sets temporal valid_from, publishes entity.upserted
- DedupWorker: runs DedupAfterExtraction on new entity IDs (terminal)
- L0 abstract: sentence-based extraction (~50 tokens), no LLM needed
- All workers registered via DomainEventBus.Subscribe()
* feat(v3): implement progressive loading with L0 auto-inject + unified search
- AutoInjector: searches episodic store, builds L0 prompt section (~200 tokens),
skips trivial messages via stopword filter
- L1Cache: in-memory LRU (500 entries, 1h TTL) for structured overviews
- UnifiedSearch: cross-tier search merging episodic + document results by score
- ContextStage integration: AutoInject callback appends memory section to system prompt
- MemorySection field added to ContextState for observability
* feat(v3): add memory_expand tool for L2 episodic retrieval
New tool: memory_expand(id) returns full episodic summary with metadata.
Complements memory_search L0/L1 results with deep L2 access.
Nil-safe: returns error message when episodic store not available.
Gateway wiring + memory_search depth param + kg_search temporal param
deferred to runtime integration phase.
* feat(v3): complete Phase 5 — tool extensions + gateway wiring
- memory_search: add depth param + episodic tier search merged with docs
- kg_search: add as_of temporal param, use ListEntitiesTemporal
- memory_expand: registered in gateway startup
- Gateway: Episodic field in Stores, PGEpisodicStore in factory,
embedding provider wired, tools connected to episodic store
* fix(v3): Phase 3 review fixes — tenant isolation + AutoInject args
- C1: Add tenant_id filter to ftsSearch, vectorSearch, List queries
(prevents cross-tenant episodic memory leaks)
- C2: Fix AutoInject callback signature — agent/tenant captured by
closure, only userMessage + userID passed explicitly
- H1: Add tenant_id to List query
* feat(v3): wire per-agent v3 flags from DB into dual-mode gate
Parse v3_pipeline_enabled, v3_memory_enabled, v3_retrieval_enabled from
agent other_config JSONB via ParseV3Flags(). Resolver now sets all flags
on LoopConfig so the existing gate in loop_run.go reads from DB.
- V3Flags struct + ParseV3Flags() + ValidateV3Flags() in store layer
- v3MemoryEnabled/v3RetrievalEnabled added to Loop, LoopConfig, PipelineConfig
- Auto-inject gated on V3RetrievalEnabled (was unconditional)
- Structured perf logging for v3 pipeline runs
- v3 flag validation on both WS agent.update and HTTP PUT endpoints
* feat(v3): wire AutoInjector into pipeline for L0 memory auto-inject
Create AutoInjector at gateway startup from episodic store, pass through
ResolverDeps → LoopConfig → Loop. Pipeline adapter builds AutoInject
callback capturing agent/tenant context via closure.
ContextStage already gates on V3RetrievalEnabled + AutoInject != nil.
* feat(v3): add tool metadata map + capability-based deny rules
Registry gains per-tool ToolMetadata map with RegisterWithMetadata()
and GetMetadata() (infers defaults from tool name when not explicit).
PolicyEngine gains DenyCapability() for RBAC integration — tools with
denied capabilities filtered at step 8 after existing 7-step pipeline.
* fix(v3): add RWMutex to PolicyEngine capability deny fields
DenyCapability() and SetRegistry() now guarded by sync.RWMutex.
FilterTools reads snapshot under RLock. Prevents data race when
capability rules are modified concurrently with tool filtering.
* feat(v3): implement delegate tool for inter-agent task delegation
New `delegate` tool wraps existing agent_links infrastructure
(CanDelegate, DelegateTargets). Supports async (fire-and-forget)
and sync (block with timeout) modes. Permission checked via
AgentLinkStore. Events emitted: delegate.sent/completed/failed.
DelegateRunFunc injected by gateway to avoid circular dependency.
* feat(v3): complete 3 deferred implementations
1. OrchestrationMode resolution: ResolveOrchestrationMode() checks
team membership → delegate links → spawn (priority order).
2. PG EvolutionMetricsStore: RecordMetric, QueryMetrics, aggregate
tool/retrieval metrics, TTL cleanup. All queries tenant-scoped.
3. BridgePromptBuilder: implements PromptBuilder interface by
delegating to existing BuildSystemPrompt(). Appends v3 memory
L0 section when enabled. Ready for template engine swap later.
* fix(v3): address code review findings on commits 5-6
- C1: CanDelegate now tenant-scoped (fail-closed on missing tenant)
- H1: Sync delegate timeout capped at 600s
- H2: Async goroutine gets 10min deadline (prevents leaks)
- H3: JSONB casts use COALESCE/NULLIF guards (handles missing fields)
- M1/M2: Remove dead code (formatVaultSection, memoryL0ToStrings)
* fix(teams): stop auto-creating agent_links for team members
Teams use agent_team_members table directly — agent_links caused
context confusion between team dispatch and delegation systems.
- Remove autoCreateTeamLinks() calls from team create + member add
- Remove link cleanup from member remove
- Remove dead autoCreateTeamLinks() function
- Append DELETE to migration 000039: clear team-created agent_links
* fix(v3): tenant isolation for all agent_links queries + PromptBuilder Instructions
- DelegateTargets, GetLinkBetween, SearchDelegateTargets,
SearchDelegateTargetsByEmbedding, DeleteTeamLinksForAgent all now
scoped by tenant_id (fail-closed on missing tenant)
- BridgePromptBuilder now maps Instructions/InstructionContent to
AGENTS.md context file (was silently dropped)
* feat(v3): wire orchestration mode + evolution metrics into agent loop
- Orchestration mode: resolver resolves mode from team/links, tool filter
hides delegate/team_tasks based on mode, prompt builder injects delegation
targets section
- Evolution metrics: non-blocking goroutine records tool execution metrics
(name, success, duration) via EvolutionMetricsStore in both v2 loop and
v3 pipeline paths (sequential + parallel)
- Fix review findings: tenant ID propagated via store.WithTenantID in
background goroutine, 5s timeout prevents goroutine leak
* feat(v3): implement suggestion engine with pluggable analysis rules
- PG EvolutionSuggestionStore: CRUD for agent_evolution_suggestions table
- SuggestionEngine: aggregates 7-day metrics, runs rules, deduplicates
pending suggestions per type before creating new ones
- 3 initial rules: LowRetrievalUsage (usage_rate<0.2), ToolFailure
(success_rate<0.1), RepeatedTool (>100 calls/week → suggest skill)
- EventSuggestionCreated event type added to eventbus
- Cron wiring deferred to gateway startup integration pass
* feat(v3): implement auto-adapt guardrails with apply/rollback
- AdaptationGuardrails: max delta per cycle, min data points, locked
params, rollback-on-drop percentage
- ApplySuggestion: applies threshold suggestions to agent other_config
JSONB, stores baseline for rollback
- RollbackSuggestion: restores baseline values from suggestion params
- EvaluateApplied: compares post-apply metrics to baseline, auto-rolls
back when quality drops beyond threshold
- Scope limited to retrieval params only (never security settings)
* feat(v3): wire evolution stores + daily/weekly cron for suggestions
- Add EvolutionMetrics + EvolutionSuggestions to Stores struct + PG factory
- Wire EvolutionMetricsStore into ResolverDeps (cmd/gateway_managed.go)
- Add gateway_evolution_cron.go: daily suggestion analysis + weekly
evaluation/rollback for applied suggestions
- Cron runs as background goroutine with 5-min timeout per cycle
* fix(v3): address code review findings on evolution engine
- C1: persist baseline parameters before marking suggestion as applied
(was building map but never saving — rollback would always fail)
- H1: add tenant_id isolation to UpdateSuggestionStatus, GetSuggestion,
and new UpdateSuggestionParameters method
* test(v3): add unit tests for orchestration, suggestions, guardrails, prompt
- orchestration_mode_test: orchModeDenyTools (4 modes) + ResolveOrchestrationMode
(4 scenarios with mock stores)
- suggestion_rules_test: LowRetrievalUsage, ToolFailure, RepeatedTool with
threshold boundary tests (at/below/above min data points)
- evolution_guardrails_test: DefaultGuardrails values + CheckGuardrails
(insufficient data, locked params, zero-min fallback)
- prompt_builder_orchestration_test: BridgePromptBuilder orchestration section
presence/absence across 4 scenarios + target content verification
* test(v3): add integration tests for evolution metrics + suggestions
- Test helper: shared PG connection with sync.Once migration, per-test
tenant+agent seed with cleanup
- Evolution metrics: RecordMetric, AggregateToolMetrics (success rate),
Cleanup (TTL deletion)
- Evolution suggestions: full CRUD, UpdateSuggestionParameters (baseline
persist), tenant isolation (cross-tenant read blocked)
- Pipeline E2E: seed 25 failed tools + 55 low-usage retrievals, verify
SuggestionEngine creates suggestions, verify dedup on second run
- Fix: migration 039 de-duped (episodic_summaries already in 037)
- Fix: NULL reviewed_by scan via sql.NullString
* feat(v3): add HTTP API handlers for evolution, vault, episodic, orchestration, v3-flags
5 new handler files exposing v3 backend stores as REST endpoints:
- evolution_handlers.go: metrics query/aggregate + suggestions CRUD
- vault_handlers.go: cross-agent document listing + search + links
- episodic_handlers.go: episodic summaries list + hybrid search
- orchestration_handlers.go: computed mode + delegate targets (read-only)
- v3_flags_handlers.go: per-agent v3 feature flag get/toggle
Store fixes from code review:
- episodic FTS: use inline to_tsvector (no stored tsv column)
- episodic: conditional user_id filter in List + Search (admin view)
- episodic: add tenant_id to ExistsBySourceID + PruneExpired
- evolution: require tenant_id in context (no struct fallback)
- evolution: check RowsAffected on suggestion updates
- vault: optional agent_id filter in ListDocuments (cross-agent)
* feat(v3): add web UI for evolution tab, v3 settings, vault page, episodic memory
Agent Detail enhancements:
- V3 Settings section: pipeline/memory/retrieval flag toggles
- Orchestration section: mode badge + delegate targets display
- Evolution section: added metrics + suggestions v3 flag toggles
- Evolution tab: Recharts metrics charts + suggestion review table
with approve/reject/rollback actions + guardrails card
New pages:
- /vault: Knowledge Vault document registry with cross-agent listing,
hybrid search dialog, document detail with wikilinks
- Memory page: added Episodic Memory tab with summary cards,
expandable details, key topic badges, and hybrid search
Infrastructure:
- HttpClient: added patch() method
- Query keys: v3Flags, orchestration, evolution namespaces
- 4 new hooks: use-v3-flags, use-orchestration, use-evolution-metrics,
use-evolution-suggestions, use-vault, use-episodic
- i18n: vault namespace (en/vi/zh), agents + memory keys updated
- Reused formatRelativeTime from lib/format.ts (eliminated 3 duplicates)
* refactor(http): add bindJSON helper and migrate all decode call sites
Replace 36 json.NewDecoder(r.Body).Decode + error blocks with bindJSON
across 20 HTTP handler files. Standardizes decode error responses to
structured writeError format. Fixes unchecked decode in handleIndexAll.
* refactor(store): adopt sqlx for PG scan operations (Phase 1+2)
Add jmoiron/sqlx v1.4.0 with camelToSnake json tag mapper.
Migrate scan-heavy PG store methods to sqlx Get/Select:
- tracing.go: GetTrace, ListTraces, ListChildTraces, GetTraceSpans, GetCostSummary
- heartbeat.go: Get, ListDue, ListLogs
- providers.go: GetProvider, GetProviderByName, ListProviders, ListAllProviders
- mcp_servers.go: GetServer, GetServerByName, ListServers
- pairing.go: ListPending, ListPaired
- agents_export_queries.go: 5 export functions
- agents_export_team_queries.go: exportTeamMembers, ExportAgentLinks
All writes (INSERT/UPDATE/DELETE), execMapUpdate, and dynamic WHERE
builders remain raw SQL. Zero behavior change.
* refactor(store): adopt sqlx for SQLite scan operations (Phase 3)
Migrate SQLite store scan methods to sqlx Get/Select:
- providers.go: GetProvider, GetProviderByName, ListProviders, ListAllProviders
- tenants.go: GetTenant, GetTenantBySlug, ListTenants, GetTenantUser, ListUsers, ListUserTenants
- mcp_servers.go: GetServer, GetServerByName, ListServers
Create sqlx_scan_structs.go with sqliteTime-aware scan structs
(providerRow, tenantRow, tenantUserRow, mcpServerRow) to handle
SQLite TEXT timestamp parsing via StructScan.
* refactor(store): migrate PG bulk scan operations to sqlx (Phase 4)
Migrate scan-heavy methods across 6 PG store files:
- tenant_store.go: GetTenant, GetTenantBySlug, ListTenants, GetTenantUser,
ListUsers, ListUserTenants — removed 3 scan helpers
- teams.go: ListTeams, GetTeam, ListMembers, ListMembersByTenant
- teams_tasks_activity.go: ListComments, ListEvents, ListFollowUps
- pending_message_store.go: ListPending, ListByHistoryKey
- skills_grants.go: ListAgentGrants
- config_permissions.go: CheckPermission
~20 scan ops converted. Files with encryption post-processing,
pq.Array, pgvector, or dynamic SQL kept raw.
* refactor(store): extract shared CamelToSnake mapper, add UUIDArray usage note
- Move camelToSnake to internal/store/column_mapper.go (DRY)
- Both pg and sqlitestore packages now import shared CamelToSnake
- Add planned-use comment on UUIDArray type
* refactor(cli): migrate commands from config.json to HTTP API, add providers/setup/TUI
- Add unified HTTP client (gateway_http_client.go) with auth, error parsing, typed generics
- Rewrite agent list/add/delete to use gateway HTTP API instead of config.json
- Rewrite channels list to HTTP API, add channels add/delete subcommands
- Replace models command with full providers CRUD (list/add/update/delete/verify)
- Add setup wizard command (provider → agent → channel post-onboard flow)
- Add Bubble Tea TUI behind build tag (tui/!tui with noop fallback)
- Update onboard next-steps to mention goclaw setup
- Add build-tui Makefile target
- Fix URL path injection (url.PathEscape on all user-supplied path segments)
- Fix UTF-8 truncation in skills description display
* refactor(store): add explicit db struct tags, fix sqlx mapper for heartbeat scan error
Switch sqlx mapper from NewMapperFunc (which only applies CamelToSnake to
field names, not tag values) to NewMapperFunc("db", CamelToSnake) with
explicit db:"column_name" tags on all store structs.
Root cause: NewMapperFunc("json", fn) sets mapFunc but not tagMapFunc,
so camelCase json tags like "agentId" were used as-is instead of being
converted to "agent_id", causing "missing destination name" scan errors.
Fix: use db struct tags as the source of truth for column mapping.
Every DB entity field gets db:"column_name", nested JSON configs and
runtime-only structs get db:"-".
* test(store): add integration tests for 13 store interfaces (70 tests)
Cover Tier 1 (critical) + Tier 2 (security) stores with integration tests
running against pgvector pg18. Coverage from 2.4% to ~54%.
Stores tested: Session, Agent, Team/Task, Memory, KnowledgeGraph, Vault,
MCP Server, API Key, ConfigPermission, Contact.
Infrastructure: fixture builders (seedTeam, seedMCPServer, etc.),
mock EmbeddingProvider, multi-tenant helpers, expanded cleanup.
* fix(store): resolve NULL scan bugs in MCP server and task metadata
- mcp_servers: COALESCE nullable TEXT columns (display_name, command,
url, api_key, tool_prefix) to prevent sqlx scan failures
- mcp_servers_access: COALESCE nullable JSONB columns in ListAgentGrants
(tool_allow, tool_deny, config_overrides) to prevent silent row drops
- teams_tasks: default task metadata to '{}' instead of nil to satisfy
NOT NULL constraint on CreateTask
- sqlx_helpers: export InitSqlx for integration test setup
* feat(pipeline): fix v3 pipeline context injection, tracing, KG temporal filters
- Pipeline context: add InjectContext + LoadSessionHistory callbacks to
ContextStage, propagate enriched ctx via state.Ctx for iteration stages
- Pipeline tracing: wrap makeCallLLM with emitLLMSpanStart/End, wrap
makeExecuteToolCall/Raw with emitToolSpanStart/End
- Token counter: switch pipeline from FallbackCounter to TiktokenCounter
- KG temporal: add valid_until IS NULL filter to all entity/relation
queries (list, search, vector, FTS, traversal CTE, stats)
- Skills: add SkillEmbedder interface for future hybrid BM25+vector search
- Cache: remove unused tenantResolve dead code from PermissionCache
- Store: fix NULL scan bugs in tracing metadata and agent skill_nudge
- Test: add TestStoreKG_TemporalFilter integration test
- UI: add v3 version badge, evolution section, memory/traces improvements
* refactor(store): migrate KG store from raw sql.Rows to sqlx StructScan
Migrate 6 knowledge graph store files from manual rows.Scan() to
pkgSqlxDB.GetContext/SelectContext with intermediate scan row structs.
- Add entityRow, relationRow, traversalRow, dedupCandidateRow structs
with json.RawMessage for jsonb and time.Time for timestamptz columns
- Add toEntity()/toRelation() converters (UnixMilli + json.Unmarshal)
- Add sqlxTx() helper for wrapping *sql.Tx with sqlx mapper
- Fix ScanDuplicates passing time.Now().Unix() to TIMESTAMPTZ column
- Fix ListEntitiesTemporal missing tenant scope (scopeClause)
- Fix SupersedeEntity missing tenant scope and tenant_id on INSERT
- Fix DedupCandidate.CreatedAt using Unix() instead of UnixMilli()
- Update agents_export_queries.go to reuse new scan row structs
- Net -160 lines of manual scan boilerplate removed
* refactor(store): migrate memory, skills, agents, sessions, mcp, cron, vault stores to sqlx
Batch migration of 19 store files from raw rows.Scan() to
pkgSqlxDB.GetContext/SelectContext with intermediate scan row structs.
Groups migrated:
- Memory: memory_docs, memory_admin, memory_search, memory_embedding_cache
- Episodic: episodic_search, episodic_summaries
- Skills: skills, skills_admin, skills_embedding, skills_export_queries
- Agents: agents (backfill+shares), agents_context, agents_export_team_standalone
- Sessions: sessions_list (List, ListPaged, ListPagedRich)
- MCP: mcp_servers_access, mcp_export_queries
- Cron: cron_exec (GetRunLog)
- Vault: vault_documents (ListDocuments, ftsSearch, vectorSearch)
- Tenant: tenant_configs (ListDisabled, ListAll)
7 new scan row files created. Net -510 lines of manual scan boilerplate.
INSERT/UPDATE/DELETE and scalar COUNT queries kept as raw SQL.
* fix(store): fix 3 sqlx scan struct db tag issues found by audit
- Fix vault FTS alias mismatch: `AS rank` → `AS score` (critical: runtime scan error)
- Fix episodic key_topics type: json.RawMessage → pq.StringArray (TEXT[] column)
- Fix agentShareRow.CreatedAt: string → time.Time, wire to output struct
* feat(providers): implement Wave 2 provider resilience and intelligence
9-phase implementation covering:
- Request middleware chain with composable body transformers
- OpenAI prompt caching, service tier, and fast mode middlewares
- Error classification (9 categories) with two-tier failover
- Model registry with forward-compat resolvers (Anthropic + OpenAI)
- Embedding providers (OpenAI + Voyage) with 1536-dim validation
- Cooldown/probe system with per-provider:model state tracking
- Markdown-aware chunking shared across 5 channels
- Session recall via FTS + pgvector on episodic summaries
- Dreaming/promotion pipeline for long-term memory consolidation
Migrations: 000040 (episodic search index), 000041 (promoted_at column)
Schema version: 39 → 41
* feat(providers): wire model registry into gateway provider construction
Create InMemoryRegistry with Anthropic + OpenAI forward-compat resolvers
at gateway startup. Pass to all Anthropic and OpenAI providers created
from both config and DB sources.
* feat(consolidation): wire DomainEventBus and consolidation pipeline
Create DomainEventBus at gateway startup, thread through resolver →
LoopConfig → Loop → PipelineDeps. Emit session.completed event after
each run finalization. Register consolidation pipeline (episodic →
semantic → KG dedup → dreaming) with event bus subscriptions.
* fix(store): fix episodic key_topics pq.Array, ON CONFLICT, and migration 040 immutability
- episodic_summaries.go Create: json.Marshal(KeyTopics) → pq.Array (text[] column)
- episodic_search.go scanEpisodic/scanEpisodicRow: json.RawMessage → pq.StringArray
- episodic_summaries.go Create: ON CONFLICT add WHERE source_id IS NOT NULL for partial index
- migration 040: add immutable_array_to_string wrapper (array_to_string is STABLE in PG)
* test(store): add 17 integration tests for skills, cron, episodic, tenant configs
- Skills store: 6 tests (CRUD, grants, tenant isolation)
- Cron store: 4 tests (job CRUD, run log sqlx scan, pagination, tenant isolation)
- Episodic store: 4 tests (summary CRUD, list, FTS search, tenant isolation)
- Tenant configs: 3 tests (tool/skill disable, list, tenant isolation)
- Test helper: add cleanup for skills, cron, episodic tables
* fix(permissions): use cron-specific permission check for cron tool (#725)
* fix(security): harden exec path exemption matching (#721)
- Add absolute path exemption for dataDir/skills-store/ (fixes skill
scripts using absolute paths like /app/data/skills-store/ being denied)
- Strip surrounding quotes before prefix matching (LLMs often quote paths)
- Reject path traversal ("..") in exempt fields to prevent escape
- Switch from "any field exempt → skip" to per-field matching: only exempt
if ALL fields that match the deny pattern are individually exempt
- Closes pipe/comment bypass vectors where an exempt path in one argument
would exempt the entire command including non-exempt paths
Includes 27 test cases covering: legitimate access, quoted paths,
path traversal, unicode bypass, pipe/comment bypass, mixed args.
* fix(permissions): use cron-specific permission check for cron tool
Cron tool was hardcoded to check `file_writer` configType via
CheckFileWriterPermission(), ignoring the `cron` configType that
the UI actually saves when granting cron permissions. This caused
agents in group chats to be denied cron access even with correct
permission configured.
Add ConfigTypeCron constant and CheckCronPermission() that checks
`cron` configType first, falling back to `file_writer`.
---------
Co-authored-by: Viet Tran <viettranx@gmail.com>
* fix(chat): load message history on first conversation click (#730)
* fix(chat): load message history when selecting existing conversation from clean state
The skipNextHistoryRef was unconditionally set when sessionKey transitioned
from empty to non-empty. This prevented loadHistory() from running when
clicking an existing conversation from the initial /chat page. The skip
was only intended for the new-chat send flow where the optimistic message
is already displayed.
Guard the skip with expectingRunRef so it only activates when a message
send is in flight.
Closes #729
* docs: add UI diff evidence for PR #730
Before/after screenshots and HTML comparison report showing
first conversation click behavior fix.
* feat(whatsapp): port native WhatsApp channel with whatsmeow from dev
Cherry-pick 0db1e93a with manual conflict resolution:
- cmd/channels_cmd.go: kept dev-v3 HTTP API approach (not config-based)
- go.mod/go.sum: merged deps, ran go mod tidy
* feat(ui): v3 web UI enhancement — branded loading, rich markdown, vault graph, sidebar polish
- Branded loading: HTML pre-loader with logo pulse/shimmer, fade-out on app ready, PageLoader logo swap
- Rich markdown: wikilinks, mermaid (lazy-loaded), math/KaTeX, callouts/admonitions plugins
- Vault graph: force-directed document graph view with table/graph toggle
- Agent filters: type filter (open/predefined) on agents page
- Sidebar: tenant name + role badge in footer
- Query keys: add vault + episodic entries
* fix(ui): address code review — mermaid XSS, Safari compat, cache key
- Change mermaid securityLevel from "loose" to "strict" (XSS prevention)
- Add requestIdleCallback fallback for Safari < 17
- Fix vault all-links query key to use sorted doc IDs (stale cache fix)
- Remove dead abortRef from MermaidBlock
- Add prefers-reduced-motion to loader animation
* docs: update CLAUDE.md with v3 architecture + complete changelog
- Add 10 new v3 internal modules to project structure (pipeline, eventbus, consolidation, tokencount, vault, workspace, etc.)
- Add native WhatsApp channel, edition system, providerresolve, updater to structure
- Update Key Patterns with 8 v3-specific patterns (pipeline, eventbus, 3-tier memory, vault, evolution, orchestration, middleware)
- Add comprehensive V3 Redesign section to changelog covering:
- 8-stage pipeline with dual-mode gate
- DomainEventBus + consolidation workers
- 3-tier memory (working/episodic/semantic)
- Knowledge Vault with wikilinks
- Self-evolution engine
- Orchestration + delegate tool
- WorkspaceContext resolver
- ModelRegistry + provider adapter
- Feature flags
- Request middleware
- sqlx migration + 70+ integration tests
* fix(security): harden file path validation, tenant isolation, and tool access
- handleSign: validate path within workspace/dataDir before signing HMAC token
- handleSign: enforce tenant-scoped restriction for RBAC-enabled editions
- handleServe: add workspace/dataDir boundary check for ft= signed requests
- handleServe: remove cross-tenant findInWorkspace fallback for ft= requests
- TenantDataDir/TenantWorkspace: guard against empty slug resolving to parent
- exec tool: add tenants/ to AllowPathExemptions for tenant skill execution
- list_files: add AllowPaths support and wire skills directory access
* docs(v3): update 16 docs + add 2 new docs for v3 architecture
Update all docs/ to reflect v3 implementation (64 commits, 31K insertions):
- 00: architecture overview with 7 new packages
- 01: agent loop with pipeline, orchestration, evolution
- 02: providers with Wave 2 resilience (middleware, failover, registry)
- 03: tools with delegate, vault_search, vault_link, memory_expand
- 04,08,10,11,14,23: targeted v3 additions
- 06: store with 6 new tables, promoted columns, sqlx
- 07: memory with 3-tier architecture, consolidation pipeline
- 18,19: HTTP/WS API with v3 endpoints and methods
- 21: evolution system (metrics, suggestions, auto-adapt)
- model-steering: model registry relationship
New docs:
- 22-v3-http-endpoints.md: 12 v3 HTTP endpoints
- 24-knowledge-vault.md: vault architecture, wikilinks, search
* feat(v3): wire delegate tool, fix pipeline callbacks, clean dead code
Wire delegate tool end-to-end:
- Register DelegateTool in gateway with DelegateRunFunc
- Implement runFn: resolve target agent, build session key, propagate tracing
- Add async announce via msgBus (reuses subagent announce handler)
- Populate context fields (TenantID, Channel, ChatID) for routing
- Set DelegationID + ParentAgentID on RunRequest for event correlation
Fix pipeline callbacks:
- BreakLoop now completes remaining stages in current iteration
- EnrichMedia signature updated to use RunState for message buffer access
- Add non-streaming event emission for channel compatibility
- Fix user message flush tracking in v3 pipeline
Clean dead code (510 LOC removed):
- Delete memory/l1_cache.go, unified_search.go (superseded by vault search)
- Delete vault/auto_injector_impl.go, retriever_impl.go (never wired)
- Delete vault/sync_worker.go (never started)
- Remove orphaned EventMemoryLint, EventSuggestionCreated constants
* feat(v3): finalize stage — emit session.completed, NO_REPLY, strip directives
- Emit session.completed event for consolidation pipeline (episodic → semantic → dreaming)
- Detect NO_REPLY before flush so silent content is persisted for context
- Strip [[...]] message directives from user-facing content (v2 parity)
- Wire StripMessageDirectives, IsSilentReply, EmitSessionCompleted callbacks
* feat(vault): full CRUD — backend endpoints, UI dialogs, content preview
Backend (5 new HTTP endpoints):
- POST/PUT/DELETE /v1/agents/{id}/vault/documents — create, update, delete
- POST/DELETE /v1/agents/{id}/vault/links — create, delete
- Server-side validation for doc_type and scope enums
- Agent ownership verification on link creation
- FK cascade handles link cleanup on document delete
Frontend (React):
- Create document dialog (title, path, type, scope)
- Edit mode in detail dialog (inline title/type/scope editing)
- Delete document with confirmation
- Create link dialog (from/to doc, link type, context)
- Delete link with inline confirmation on badges
- Content preview (collapsible, lazy-loads via /v1/storage/files/)
- Mutation hooks with query invalidation
- i18n keys for en/vi/zh
* fix(v3): critical pipeline parity fixes — ChatRequest, reasoning, passback, media
- Enrich ChatRequest with all provider options (temperature, sessionKey,
agentID, userID, channel, workspace, tenantID) matching v2
- Add ResolveReasoningDecision for thinking models (o3, DeepSeek-R1, Kimi)
- Wire uniquifyToolCallIDs to prevent OpenAI 400 on duplicate IDs
- Add assistant message passback (Phase, RawAssistantContent) for Anthropic
- Emit block.reply for intermediate content (non-streaming channels)
- Add ContentSuffix append + ForwardMedia merge in FinalizeStage
- Build final assistant message with MediaRefs for session persistence
- Use effectiveMaxTokens() + OptMaxTokens constant
- Guard truncation retry on len(ToolCalls) > 0 + parseError check
- Accumulate ThinkingTokens in usage
- Deduplicate emitRun closure (shared from callbackSet)
- Fix EnrichMedia to receive RunState with actual messages
- Persist user message in makeFlushMessages (matching v2)
* fix(store): coerce NOT NULL JSONB columns to empty object on agent update
When switching provider away from ChatGPT OAuth, the UI sends
chatgpt_oauth_routing: null which violates the NOT NULL constraint.
Coerce null → '{}' for all NOT NULL JSONB promoted columns:
chatgpt_oauth_routing, reasoning_config, workspace_sharing,
shell_deny_groups, kg_dedup_config.
* feat(v3): v3 info modal redesign, agent links CRUD tab, sidebar rename
- Rewrite v3 info modal with 8 feature cards (pipeline, memory,
retrieval, vault, evolution, orchestration, resilience, registry)
with v2→v3 comparisons, stat badges, full i18n (en/vi/zh)
- Add tabbed layout to teams page: Agent Teams | Agent Links tabs
- Agent Links tab with full CRUD via existing WS RPC methods
(list/create/edit/delete) with Radix Select, Combobox, mutual
agent exclusion in create dialog
- Sidebar menu renamed to "Agent Link & Team"
- Backend: add source_display_name, source_emoji, target_emoji
to agent link joined queries for consistent display
* fix(store): include personal chats in cron delivery targets
Add 'user' contact_type to ListDeliveryTargets SQL filter in both
PG and SQLite stores. Previously only group/topic contacts appeared
in cron channel/chat dropdowns.
* fix(v3): duplicate messages, missing thinking, span numbering
- ThinkStage: skip AppendPending for final answer (no tool calls),
let FinalizeStage build the definitive message with sanitization
and MediaRefs — fixes duplicate assistant messages in session history
- RunResult: add Thinking field, propagate through v2 finalizeRun,
v3 convertRunResult, run.completed event, and chat.send response
- UI: capture thinkingRef before clearing on run.completed, include
in final message object so thinking renders without page refresh
- Span numbering: pass Iteration+1 in v3 callback to match v2's
1-based iteration display in trace span names
* fix(v3): wire delegation targets into BuildSystemPrompt
buildOrchestrationSection() was implemented and tested but never
wired into the actual BuildSystemPrompt() flow. Only the unused
BridgePromptBuilder had it. Add DelegateTargets + OrchMode fields
to SystemPromptConfig and inject "## Delegation Targets" section
so agents with agent_links see their delegation targets in prompt.
* fix(v3): sync mediaResults from bridgeRS to pipeline state
syncBridgeToState copied loopKilled, asyncToolCalls, deliverables
from the v2 bridgeRS but missed mediaResults. Tool results with
MEDIA: prefix were extracted by processToolResult into bridgeRS
but never propagated to state.Tool.MediaResults — causing
FinalizeStage to produce empty MediaRefs and RunResult.Media.
* fix(v3): populate SessionCompletedPayload in session.completed events
Both v2 loop and v3 pipeline emitted session.completed events with
nil Payload, causing episodicWorker type assertion to fail silently.
Episodic summaries were never created.
- Expand EmitSessionCompleted callback to pass msgCount, tokensUsed,
compactionCount from pipeline state
- V3 path: build payload from state.Messages.TotalLen(),
state.Think.TotalUsage, state.Compact.CompactionCount
- V2 path: build payload from history + rs.totalUsage +
sessions.GetCompactionCount()
* fix(v3): remaining pipeline parity gaps — skill postscript, team task count
- Add SkillPostscript callback to PipelineDeps + FinalizeStage,
matching v2's skill evolution nudge after complex tool runs
- Wire makeSkillPostscript() in adapter with same logic as v2
(skillEvolve + skillNudgeInterval + totalToolCalls threshold)
- Sync teamTaskCreates from bridgeRS to pipeline EvolutionState
(was already syncing teamTaskSpawns but missed creates)
* fix(evolution): add JSON struct tags to metric aggregates
ToolAggregate and RetrievalAggregate had no JSON tags, causing
Go to marshal field names as PascalCase (ToolName, CallCount)
while the UI expects snake_case (tool_name, call_count). Charts
rendered empty containers with no data bars.
Also change AvgDuration (time.Duration) to AvgDurationMs (float64)
for JSON-friendly serialization — time.Duration marshals as
nanoseconds which is unusable in frontend.
* fix(v3): add debug logging to episodic worker for consolidation pipeline
Add INFO/WARN logs at entry, summary decision, and creation points
to diagnose why episodic_summaries table stays empty in production.
* chore: silence noisy tenant_cache debug logs
* refactor(ui): replace v3 settings section with engine version picker + tabbed info modal
Replace flat toggle list with radio-style version cards (v2/v3) and
feature mini-cards. Redesign v3 info modal from single scroll to 3-tab
layout (Core Engine, Memory & Knowledge, Orchestration) with Lucide
icons and v2→v3 comparison cards.
- Add batchUpdate to use-v3-flags hook for atomic v2 switch
- Create engine-version-section with VersionCard + FeatureMiniCard
- Create v3-info-modal/ with 5 modular components (<45 LOC each)
- Add i18n keys (detail.engine + v3Info.tabs) for en/vi/zh
- Wire into agent-overview-tab, agent-header, agent-card
- Delete v3-settings-section.tsx + agent-v3-info-modal.tsx
* feat(v3): pass media files through delegate tool results
Delegate tool now carries media files (images, audio, etc.) produced
by the delegatee back to the parent agent. DelegateRunFunc returns
DelegateResult{Content, Media} instead of plain string.
- Add DelegateResult struct with Content + Media fields
- Convert agent.MediaResult to bus.MediaFile in gateway wire
- Attach media to sync result and async announce message
- Add MediaCount to DelegateCompletedPayload for observability
- Set MetaParentAgent in async announce metadata
* merge: bring main bug fixes into dev-v3
Cherry-pick 8 commits from origin/main:
- fix(telegram): handle group-to-supergroup migration (#698)
- feat(providers): add OpenRouter identification headers (#705)
- fix: deterministic prompt ordering for LLM cache hit (#719)
- fix(security): harden exec path exemption matching (#721)
- fix: invalidate storage size cache on delete and move (#726)
- fix: use errors.Is() for sentinel comparisons (#727)
- fix(desktop): add defaultValues to form dialogs (#737)
- Release: credential resolver, WhatsApp native, exec hardening (#754)
Conflict resolution:
- store/pg exports: accept main's errors.Is() additions
- shell.go: accept main's extracted matchesAnyPathExemption helper
- gateway_providers: merge both WithAnthropicName + WithAnthropicRegistry
- loop_types + resolver: merge v3 fields (OrchMode, DelegateTargets,
EvolutionMetricsStore) with main's new UserResolver/ContactStore
- gateway_setup: keep dev-v3's tenant-scoped path exemptions
- channels_cmd: keep dev-v3's HTTP API approach
* feat(v3): add foundation packages for architecture refactor (Phase 1)
Purely additive — zero changes to existing files. Creates shared types
and helpers that Phase 2-4 will migrate callers to:
- internal/store/base/: Dialect interface, BuildMapUpdate, nullable/JSON
helpers, scope clause builder, table metadata (44 tests)
- internal/orchestration/: ChildResult capture from v2/v3, media type
conversion with round-trip tests (10 tests)
- internal/providers/sse_reader.go: Shared SSE scanner replacing inline
bufio.Scanner boilerplate in 3 providers (8 tests)
* refactor(store): unify pg/ and sqlitestore/ helpers via base/ package (Phase 2)
- Create PG and SQLite dialect implementations (base.Dialect interface)
- Replace 15 duplicate helpers in pg/helpers.go with aliases to base.*
- Replace 13 duplicate helpers in sqlitestore/helpers.go with aliases
- Rewrite execMapUpdate and execMapUpdateWhereTenant to use
base.BuildMapUpdate with dialect-specific placeholders
- Rewrite scopeClause/scopeClauseAlias as thin wrappers around
base.BuildScopeClause
- Remove duplicate execMapUpdateWhereTenant from pg/agents.go
pg/helpers.go: 267→175 LOC, sqlitestore/helpers.go: 226→130 LOC
* refactor(orch): extract BatchQueue[T] generic for announce queues (Phase 3)
- Create internal/orchestration/batch_queue.go: generic producer-consumer
queue replacing duplicated sync.Map+mutex pattern (10 tests, race-safe)
- Simplify cmd/gateway_announce_queue.go: team queue uses BatchQueue,
removes announceQueueState/getOrCreate/drain/tryFinish (~40 LOC saved)
- Simplify cmd/gateway_subagent_announce_queue.go: subagent queue uses
BatchQueue, same pattern reduction (~40 LOC saved)
- Update cmd/gateway_consumer_handlers.go: callers use new signatures
* fix: remove defer accumulation in announce loop, clean comment tombstone
- Remove `defer ptd.ReleaseTeamLock()` inside for loop that accumulated
deferred calls per iteration (explicit ReleaseTeamLock already called)
- Remove dead comment tombstone in pg/agents.go
* fix: scope defer in announce loop via closure for panic safety
Wrap announce processing in closure so defer ptd.ReleaseTeamLock()
runs once per iteration instead of accumulating. Explicit release
still called for normal path; defer catches panics.
* refactor(providers): wire shared SSEScanner into 3 providers (Phase 4b)
Replace inline bufio.Scanner+SSE parsing boilerplate with shared
SSEScanner from sse_reader.go in:
- openai.go: data-only SSE (OpenAI, DashScope, Kimi)
- codex.go: data-only SSE (OpenAI Codex)
- anthropic_stream.go: event+data SSE (uses EventType() for switch)
Removes ~24 LOC of duplicated scanner setup + manual line parsing.
* refactor(agent): force v3 pipeline, remove v2 runLoop (Phase 4A)
- Delete runLoop() from loop.go (~745 LOC removed), keep shared helpers
(resolveToolCallName, hasParseErrors, truncateToolArgs)
- Remove v2/v3 gate in loop_run.go: always call runViaPipeline()
- Remove v3PipelineEnabled field from Loop struct + LoopConfig + resolver
- Always resolve workspace in loop_context.go (was behind v3 gate)
- Deprecate PipelineEnabled in V3Flags (kept for backward compat parsing)
All agents now always use v3 pipeline. No behavioral change for agents
that already had v3_pipeline_enabled=true (which was all production agents).
* refactor(gateway): decompose gateway.go from 1295 to 476 LOC (Phase 4B)
Extract sections of runGateway() into focused files:
- gateway_deps.go: gatewayDeps struct for shared state
- gateway_http_wiring.go: wireHTTPHandlersOnServer (~207 LOC)
- gateway_events.go: event subscribers + teamTaskEventType (~367 LOC)
- gateway_lifecycle.go: signal handling, shutdown, server start (~222 LOC)
- gateway_tools_wiring.go: cron/heartbeat/session tool wiring (~116 LOC)
Also extracted: startCronAndHeartbeat, makeDelegateAnnounceCallback.
Pure structural refactoring — no behavior change.
* test(agent): add v3 force migration guard tests
Verify v2 runLoop is deleted, V3PipelineEnabled field removed from
LoopConfig, and V3Flags backward compat parsing still works.
Compile-time guards prevent accidental re-introduction of v2 code.
* feat(delegate): wire ChildResult + fix media passthrough (Phase 3 gap)
- Use orchestration.CaptureFromRunResult in delegate run callback
to standardize result capture via ChildResult
- Use MediaResultToBusFiles in CaptureFromRunResult (DRY)
- Fix delegate_tool.go metadata key: delegate_id → delegation_id
* refactor(ui): remove v2/v3 pipeline toggle, always show V3 (Phase 4E)
- engine-version-section.tsx: remove pipeline toggle, show V3 read-only
- use-agent-version.ts: always return "v3" (no flag check)
- use-v3-flags.ts: remove v3_pipeline_enabled from toggleable flags
- Update i18n strings (en/vi/zh): remove v2-specific tooltip text
* test: add Phase 5 test infrastructure for v3 architecture refactor
- pg/pg_dialect_test.go: 4 tests for PG Dialect (placeholder, transform,
returning, interface compliance)
- sse_reader_test.go: 4 edge case tests (empty data, scanner error,
event type persistence, no data after [DONE])
- gateway_announce_format_test.go: 10 tests for team + subagent
announce formatting (single/batch, success/failure, snapshot)
Coverage: base/ 96%, orchestration/ 100%, providers/ 57%
* chore: remove stale runLoop references + apply go fix (Phase 6)
- Update comments referencing deleted runLoop in pipeline callbacks,
loop_types, stage.go
- go fix: reflect.TypeFor, range over int, strings.Builder
* docs: update architecture docs for v3 refactor completion (Phase 6)
- CLAUDE.md: add store/base/, orchestration/ to project structure;
remove v2 runLoop and dual-mode gate references; rename Pipeline (v3)
to Pipeline; add SSEScanner and BatchQueue to key patterns
- docs/00-architecture-overview.md: update module map with new packages,
remove [V3] markers (now standard)
- docs/17-changelog.md: add V3 Architecture Refactor entry (6 phases)
* feat(evolution): skill draft auto-generation + go fix cleanup
- Add skill draft template generation from evolution suggestions
- Wire skill apply endpoint in evolution HTTP handlers
- Apply go fix across codebase (range-over-int, reflect.TypeFor, etc.)
- Minor refactors: simplify switch/case, reduce string builder allocs
- Gateway deps: add skills loader field
* perf(prompt): deterministic tool order + Anthropic cache boundary split
Sort tool names in buildToolingSection for cache-stable output.
Insert GOCLAW_CACHE_BOUNDARY marker before Time section; Anthropic
provider splits system prompt into 2 blocks (stable cached, dynamic not).
Backward compat: no marker → single cached block.
* perf(prompt): optimize cache boundary position + add Execution Bias
Move Memory Recall and stable context files (AGENTS.md, TOOLS.md,
USER_PREDEFINED.md) above cache boundary. Dynamic per-user files
(USER.md, BOOTSTRAP.md) stay below. Add Execution Bias section
(full mode only) forcing action-oriented tool use. Fix duplicate
header when Project Context split across boundary.
* feat(prompt): add PromptMode task/none + 3-layer resolution
Expand PromptMode from full|minimal to full|task|minimal|none.
Task mode = enterprise automation: keeps Tooling, Execution Bias,
Safety-slim, Persona-slim, Skills-search, MCP-search, Memory-slim,
Workspace, Runtime, Delegation. Drops verbose sections (Tool Call
Style, Self-Evolution, Spawning, Recency, etc.).
None mode returns identity line only. 3-layer mode resolution:
runtime override > auto-detect (subagent/cron) > agent config
(other_config.prompt_mode) > default (full).
* feat(prompt): provider prompt contributions (stable/dynamic/overrides)
Add PromptContribution struct + PromptContributor interface for
provider-specific prompt customizations. Providers can inject
StablePrefix (before cache boundary), DynamicSuffix (after boundary),
or override sections by ID (e.g. execution_bias). Nil-safe: providers
that don't implement the interface get default behavior.
* feat(prompt): pinned skills with hybrid inline+search mode
Add per-agent pinned_skills config (max 10, from other_config JSONB).
Pinned skills always inline in prompt via BuildPinnedSummary.
Non-pinned discovered via skill_search. Hybrid section shows both
pinned XML and search instructions. Works in full and task modes.
* fix(prompt): validate prompt_mode from DB before cast
Reject unknown prompt_mode values from OtherConfig JSONB
(e.g. typos like "taks") by checking against validPromptModes set.
Invalid values default to "" (full mode). Prevents broken prompts
where no mode flags match.
* fix(prompt): wire SectionIDToolCallStyle for provider override
Tool Call Style section now uses sectionContent() like Execution Bias,
allowing providers to override it via PromptContribution.SectionOverrides.
* feat(store): implement 9 SQLite store backends for v3 parity
Close feature gap between PostgreSQL and SQLite (desktop/lite) editions.
96 methods across 9 stores: AgentLinks, SubagentTasks, SecureCLI,
SecureCLIGrants, EvolutionMetrics, EvolutionSuggestions, Episodic,
KnowledgeGraph, Vault. Schema v8→v9 adds 4 tables.
Key design decisions:
- LIKE-based search replaces tsvector/FTS5 (unavailable in modernc.org/sqlite)
- Go-side StringSimilarity for KG dedup (replaces pgvector cosine)
- Recursive CTE traversal with comma-delimited path cycle detection
- AES-256-GCM encryption on all SecureCLI read/write paths
- F15: SecureCLI disabled when EncryptionKey empty
- ON CONFLICT DO UPDATE (never INSERT OR REPLACE) to preserve FK cascades
* docs(store): add SQLite parity section to store data model docs
Document 9 new SQLite store implementations, schema v9, and
feature parity gaps (LIKE vs FTS, Jaro-Winkler vs vector dedup).
* fix(docker): skip web-builder stage when ENABLE_EMBEDUI=false
Use BuildKit conditional stage pattern so web-builder is not executed
when embedding is disabled. Also update pnpm-lock.yaml for 6 new deps
that were missing from the lockfile (markdown/math/mermaid packages).
* feat(vault): embed metadata.summary for richer vector search
Include summary from metadata JSONB in embedding text (title + path +
summary) for better semantic search. Update tsvector generated column
to include summary in FTS index. Zero new columns — summary stored in
existing metadata JSONB field. Backward compat: docs without summary
still embed title+path only.
* fix(vault-ui): wider detail dialog, markdown rendering, create tooltip
- Detail dialog: sm:max-w-lg → sm:max-w-2xl, content area 200→300px
- Content preview: render markdown via MarkdownRenderer instead of <pre>
- Create button: add tooltip explaining "select agent first" when disabled
* feat(ui): prompt_mode dropdown + pinned_skills multi-select
Add PromptSettingsSection to agent overview tab:
- prompt_mode: Select dropdown (full/task/minimal/none)
- pinned_skills: Tag input with max 10, click to remove
Both save to agent other_config JSONB via existing onUpdate flow.
Self-contained save button appears only when values change.
* fix(vault-ui): enlarge dialog to max-w-4xl, constrain markdown heading sizes
Dialog sm:max-w-2xl → sm:max-w-4xl. Markdown headings capped at
text-base/text-sm via Tailwind child selectors to prevent oversized
h1/h2 in content preview. Content area raised to 400px.
* refactor(ui): move pinned skills to dedicated section with skill select
Split pinned_skills out of PromptSettingsSection into PinnedSkillsSection.
Uses useAgentSkills hook for proper dropdown with granted skills list.
Placed right below SkillsSection in agent overview tab. Badge chips
with X to remove, Select dropdown to add. Max 10 enforced.
* fix(ui): add border wrap to capabilities section for visual consistency
* feat(ui): redesign prompt mode as compact cards, replace v3 badge
- Rewrite prompt settings from select dropdown to 2×2 compact cards
with lucide icons (Zap/Wrench/Package/CircleOff) and ring selection
- Move prompt settings to top of agent overview tab
- Replace v3 badge with prompt mode badge in header, card, and list row
- Add i18n keys for prompt mode labels/descriptions (en/vi/zh)
- Extract readPromptMode() to shared agent-display-utils
- Remove dead useAgentVersion hook and v3Tooltip/v2Tooltip i18n keys
- Remove v3 badge from EngineVersionSection (keep feature toggles)
- Use cn() for badge class composition with twMerge safety
* feat(ui): add section tags and token estimates to prompt mode cards
Each card now shows which system prompt sections are included
(Persona, Tools, Safety, Skills, MCP, Memory, etc.) as compact
tags, plus estimated base token range (~2-4K for full, ~10 for none).
Helps users understand the impact of each mode at a glance.
* fix(ui): correct token estimates with actual tiktoken measurements
Measured via tiktoken (cl100k) on realistic config:
full=~1.7K+, task=~1.1K+, minimal=~660, none=~6 base tokens.
The "+" suffix indicates context files/skills/MCP add more.
* fix(ui): use tiktoken-measured token ranges for prompt mode cards
Measured across 3 scenarios (bare/typical/heavy) via tiktoken cl100k:
full=~500-2.9K, task=~350-1.2K, minimal=~350-820, none=~6 tokens.
Ranges reflect real configs from minimal (3 tools) to heavy
(10 tools, long persona, MCP, sandbox, pinned skills).
* fix(ui): use production-measured token counts for prompt mode cards
Measured via tiktoken on real production agent (tieu-ho) with
4 context files (AGENTS.md, SOUL.md, IDENTITY.md, USER_PREDEFINED.md),
14 tools, memory, KG, skills, Telegram channel:
full=~3.1K, task=~2.2K, minimal=~1.9K, none=~6 tokens.
* refactor(ui): accurate section tags per prompt mode from systemprompt.go
Section tags now match exact gating logic in BuildSystemPrompt():
- full: persona, tools, exec bias, call style, safety, skills,
MCP, memory, sandbox, evolution, channel hints (11 sections)
- task: style echo, tools, exec bias, safety (slim), skills (search),
MCP (search), memory (slim) (7 sections)
- minimal: tools, safety (2 sections + shared context files)
- none: no tags shown, no token count (trivially ~6 tokens)
Removed workspace/identityOnly tags (shared across all modes).
* feat(ui): replace engine version toggles with V3 capabilities modal
- Remove 3 toggle switches (Pipeline, Memory, Retrieval) from agent
detail — all features are now always-on for v3 agents
- Replace with compact static badges layout
- New V3 Capabilities modal with 4 tabs:
Pipeline (8-stage flow), Memory (L0/L1/L2 tiers),
Knowledge (KG, Vault, Dreaming), Orchestration (Delegate, Evolution)
- Each tab has Lucide icons and technical descriptions of how
features actually work
- Separate i18n namespace v3-capabilities in en/vi/zh locales
* refactor(ui): move V3 badge to agent header, remove engine version section
- Add clickable V3 badge next to agent name in header
- Clicking opens V3 Capabilities modal
- Remove Engine Version section from overview tab entirely
- Remove unused EngineVersionSection import
* feat: v3 prompt engine overhaul — 7-phase restructuring
Phase 1: Fix mode resolution — subagent/cron cap at task (not minimal),
heartbeat stays minimal, pinned skills injected in all modes, USER_PREDEFINED
added to minimal allowlist.
Phase 2: Context file restructuring — new CAPABILITIES.md (domain expertise,
separated from SOUL.md), AGENTS_MINIMAL.md for heartbeat sessions, both added
to stable context files and minimal allowlist.
Phase 3: Summoner update — all 4 prompt builders generate CAPABILITIES.md,
fallback 2-call stores capabilities alongside SOUL.md.
Phase 4: Open agent deprecation — creation silently upgrades open→predefined
in both HTTP and WS endpoints.
Phase 5: Bootstrap auto-contact — sender name from channel metadata injected
into bootstrap context for 1-turn onboarding (DM only).
Phase 6: System prompt preview — GET /v1/agents/{id}/system-prompt-preview
endpoint with mode param, token counting, section parsing.
Phase 7: Agent creation UX — removed open agent type toggle, schema simplified,
description always required, prompt mode cards updated with v3 token estimates.
* fix(vault-ui): redesign detail dialog and link dialog UX
- Vault detail: show content preview by default (not collapsed),
move type/scope to header badges, hash to subtle footer
- Link dialog: replace native <select> with searchable Combobox
for target document and link type (5 presets + custom)
- Fix Vietnamese i18n: add proper diacritics to v3-capabilities
* fix(i18n): keep prompt section badges in English across all locales
Technical terms like Persona, Tools, Safety, Memory, Skills, MCP,
Sandbox, Evolution should not be translated — they are UI labels
matching system prompt section names.
* feat(ui): system prompt preview in Files tab + README restructure
Files tab: add "System Prompt" item in sidebar below context files.
When selected, shows readonly preview with mode selector (full/task/
minimal/none), token count badge, and cache boundary highlighting.
Fetches from GET /v1/agents/{id}/system-prompt-preview.
README: remove Claw Ecosystem comparison tables, remove OpenClaw port
reference, rename "What Makes It Different" to "Core Features" with v3
additions (8-stage pipeline, 4-mode prompt, 3-tier memory, knowledge
vault, self-evolution), slim built-in tools to category summary table.
* feat: replace architecture images with v3 sketchnotes
Add 9 new architecture sketchnote images generated for v3:
- 8-Stage Agent Pipeline
- 4-Mode Prompt System
- 3-Tier Memory Architecture
- Multi-Tenant Architecture
- Agent Orchestration
- Knowledge Vault
- Provider Adapter System
- Self-Evolution System
- DomainEventBus
Remove old architecture images (architecture.jpg, goclaw_multi_tenant.png,
agent-delegation.jpg, agent-teams.jpg).
Update README to reference new images in Architecture and Orchestration
sections. Consolidate orchestration section (remove separate delegation
and teams subsections).
* feat(readme): add remaining 4 architecture sketchnotes with sections
Add Knowledge Vault, Self-Evolution, Provider Adapters, and Event-Driven
Architecture sections with corresponding sketchnote images and concise
descriptions. All 9 v3 architecture diagrams now in README.
* feat: reorder README architecture images, evolution guardrails fix, memory tools enhancement
README: reorder architecture sketchnotes (Multi-Tenant first), remove
pinnedSkills from task mode sections badge.
Backend: evolution guardrails fix, memory auto-injector and tools
enhancement, gateway HTTP wiring update.
* feat(desktop): add 12 v3 feature sections to agent detail panel
Add evolution expansion (skill learning, v3 flags), prompt mode selector,
evolution dashboard tab (CSS bar charts, suggestions, guardrails),
thinking/reasoning config, orchestration display, context pruning,
compaction, subagents (lite-limited), tool policy, sandbox config,
and pinned skills management.
Extract agent detail state into use-agent-detail-state hook.
Add getWithParams to ApiClient. Add i18n keys to en/vi/zh locales.
* fix(vault): team-scope security — prevent cross-team data corruption and leaks
- Add team_id UUID + custom_scope to vault_documents (PG migration 043, SQLite migration 10)
- COALESCE-based UNIQUE prevents silent cross-team data overwrite on ON CONFLICT
- PG trigger auto-corrects scope to 'personal' on team deletion (ON DELETE SET NULL)
- Store layer: TeamID filter on all query methods, RunContext-based team scoping
- CreateLink validates same-tenant + same-team boundary (defense in depth)
- VaultInterceptor: infer scope from RunContext, add AfterWriteMedia for binary files
- Wire VaultInterceptor into 5 tools (create_image, create_video, create_audio, tts, edit)
- HTTP handlers: team membership validation via HasTeamAccess, non-owner defaults to personal
- GetBacklinks: single JOIN + LIMIT 100 replaces N+1, VaultBacklink struct with team_id
- vault_search/vault_link/vault_backlinks tools read TeamID from RunContext
- Backlinks filtered by team boundary to prevent title exfiltration
Addresses 7 original + 13 red-team findings (1 CRITICAL, 5 HIGH, 3 MEDIUM).
* feat(evolution): allow CAPABILITIES.md self-evolution, backfill existing agents, cleanup v3 dead flags
- Allow self-evolving predefined agents to read/write CAPABILITIES.md
(domain expertise) in addition to SOUL.md (style/tone)
- Add CAPABILITIES.md to contextFileSet (DB routing) and protectedFileSet
(group chat permission check)
- Update buildSelfEvolveSection() system prompt to mention CAPABILITIES.md
- Merge ensureUserPredefined + ensureCapabilities into single
ensureBackfillFiles() — one DB query instead of two, with error logging
- Remove dead V3MemoryEnabled/V3RetrievalEnabled from PipelineConfig,
Loop, LoopConfig, resolver, and adapter (always true at runtime)
- Keep fields in V3Flags struct for JSONB backward compat with
deprecation comments
- Add 10 new tests covering interceptor read/write, prompt section,
and backfill logic
* feat(export): add v3 sections to agent import/export pipeline
- Phase 1: KG temporal fields (valid_from/valid_until) in export/import
- Phase 2: Episodic summaries section (episodic/summaries.jsonl)
- Phase 3: Evolution metrics + suggestions (evolution/*.jsonl)
- Phase 4: Vault documents + links (vault/*.jsonl, two-pass link resolution)
- Phase 5: ImportSummary expanded to 17 fields, cron/overrides UPSERT dedup
- Add backup/pgpass.go: secure .pgpass credential handling for pg_dump
* feat(backup): system and tenant backup/restore with S3 support
Phase 6: System backup — pg_dump + filesystem tar.gz, .pgpass security,
preflight check, CLI (goclaw backup) + HTTP API with SSE progress
Phase 7: System restore — psql restore, path traversal protection,
active connection check, --force/--dry-run/--skip-db/--skip-files
Phase 8: S3 integration — upload/download via AWS SDK v2, credentials
encrypted in config_secrets (AES-256-GCM), custom endpoint support
Phase 9: Tenant backup/restore — per-table JSONL export (43 tables,
5-tier FK ordering), 3 restore modes (upsert/replace/new-tenant),
tenant admin permission checks, CLI + HTTP endpoints
* feat(backup): add table registry validation + gate tenant backup for PG-only
- DiscoverTenantTables() queries information_schema for tables with tenant_id
- ValidateTableRegistry() cross-checks hardcoded registry vs actual schema,
warns about unregistered tables to prevent silent data loss
- TenantBackup() runs validation before export
- Tenant backup/restore gated for PG-only — SQLite Lite edition has only
master tenant, returns clear error directing to system backup instead
* fix(backup): address code review security + correctness findings
- C1: Remove backup_path from S3 upload — prevent file exfiltration,
require backup_token only
- H1: createNewTenant fails explicitly on slug conflict instead of
silent NOOP that orphans imported data
- H2: Validate JSONL column names against safe regex before SQL
interpolation — prevent SQL injection from crafted archives
- H3: Replace EOF string comparison with io.Copy in addFileToTar
- H6: Add atomic concurrency guard — reject concurrent backup/restore
* feat(web): add backup & restore admin page with 4 tabs
New admin-only page at /backup-restore with System Backup, System
Restore, S3 Config, and Tenant Backup tabs. Reuses existing
useSseProgress hook and OperationProgress component for real-time
SSE streaming. Includes full i18n support (en/vi/zh).
* feat(web): system prompt preview modal + CAPABILITIES.md backfill + pipeline parity
- Add Eye button in agent header → opens wide modal with markdown-rendered system prompt preview
- CAPABILITIES.md one-time startup backfill for pre-v3 agents (single SQL INSERT WHERE NOT EXISTS)
- Add CAPABILITIES.md to allowedAgentFiles so it shows in Files tab
- Refactor preview API to reuse pipeline's BuildSystemPrompt via BuildPreviewPrompt()
- Resolve actual tool names from registry, provider contributions, sandbox config, shell deny groups
- Resolve team context (TEAM.md virtual file, members, workspace, delegation targets) from DB
- Add IsMinimalAllowed() for mode-aware context file filtering
- Support ?user_id= query param for per-user context file preview
* feat(prompt): redesign 4 system prompt modes with tiered context files
- Add AGENTS_CORE.md (minimal) and AGENTS_TASK.md (task) templates
- Implement ModeAllowlist() for per-mode context file filtering
- Wire filtering into pipeline (loop_history) and preview API
- Upgrade none mode from single-line to functional tool-call prompt
with slim safety, pinned skills, MCP search, workspace, runtime
- Task mode now gets full persona (SOUL.md + IDENTITY.md)
- Add prompt_mode selector to agent creation dialog
- Mode upgrade warning toast on save
- Skip summoning modal for none/minimal mode agents
- SQL migration 000044: seed AGENTS_CORE/TASK, remove AGENTS_MINIMAL
- SQLite: fix migration v9 duplicate column, v10 COALESCE in UNIQUE
- Add ModeAllowlist tests, none mode tests, SQLite schema tests
- Update i18n (en/vi/zh) with new section keys and mode descriptions
Token targets: full ~4.8K, task ~1.3K, minimal ~570, none ~640
* refactor(web): extract PromptModeCards shared component
Reuse same card layout (icon + name + desc + tokens + section tags)
in both agent creation dialog and agent settings page.
Create dialog uses compact=true to hide section tags.
* fix(backup): align preflight API contract + add package guidance UX
Backend preflight endpoint now returns flat JSON matching frontend
interface (pg_dump_available, disk_space_ok, size metrics) instead of
internal checks array. Adds DirSize/FormatBytes helpers.
Frontend: PageHeader component for consistency, amber alert banner
with link to /packages when pg_dump missing, mobile grid fix.
* feat(web): system prompt preview modal + CAPABILITIES.md backfill + pipeline parity
* feat(vault): async enrich worker for auto summary + semantic linking
Add EventBus-driven worker that generates LLM summaries for vault
documents, embeds them via pgvector, and auto-creates semantic links
between related docs using cosine similarity search.
- Skip embedding in UpsertDocument when summary is empty
- Add UpdateSummaryAndReembed + FindSimilarDocs to VaultStore interface
- Wire enrichment events in AfterWrite/AfterWriteMedia interceptors
- BatchQueue batching for burst writes, bounded dedup (10K cap)
- 5-minute LLM timeout, 0.7 similarity threshold, top-5 neighbors
- SQLite: summary-only (no vector ops, graceful noop)
* refactor(web): phase 1 quick wins — grid breakpoints, silent catches, useQuery migration
- Fix 6 grid-cols-2 without mobile breakpoint → grid-cols-1 sm:grid-cols-2
- Replace 9 silent .catch(() => {}) with console.error for debuggability
- Migrate system-prompt-preview.tsx from useEffect+useState to useQuery
- Add agent column + path tail truncation to vault documents table
* refactor(web): phase 2 split god components into sub-components
- cron-overview-tab: extract schedule, delivery, lifecycle sections
- channel-detail: extract timeline hook + dialogs component
- agent-advanced: extract state utils (deriveState, buildPayload)
- heartbeat-config: add deriveFormDefaults helper
- tenant-backup: split into backup + restore sections
- board-container: extract useBoardTasks hook
- provider-form: extract standard form fields
- contacts: extract contacts table component
* refactor(web): phase 3 form standardization — RHF+Zod migration + field errors
- Migrate 6 forms from useState to React Hook Form + Zod validation
- Create 4 new schema files (api-key, vault, login, s3-config)
- Add inline field error display to memory, mcp, heartbeat, agent-create forms
- Replace raw <input> with <Input> in login forms for consistent styling
* refactor(web): phase 4 lazy-load 20 dialog components + memory co-location
- Convert 20 heavy dialogs from eager to React.lazy + Suspense
- All dialogs use named exports with .then(m => ({ default: m.X })) wrapper
- Suspense fallback={null} per-dialog (invisible during chunk load)
- Move memory page components into documents/, knowledge-graph/, episodic/ subdirs
* refactor(web): phase 5 data fetching polish — staleTime tiers + optimistic updates
- Apply 3-tier staleTime policy: static 5min, standard 60s, realtime 5-15s
- Update 31 hooks with explicit staleTime (37 useQuery call sites)
- Add optimistic updates to builtin-tools, v3-flags, cron, mcp toggles
* refactor(web): phase 6 styling standardization — CVA, design tokens, font utility
- Convert input, textarea, select trigger to CVA with size variants
- Add font-mono-code utility class, replace 4 inline fontFamily styles
- Add text-2xs (10px) and text-xs-plus (11px) design tokens
- Replace 241 arbitrary text-[10px]/text-[11px] with token classes
- Co-locate memory page knowledge-graph files into subdirectory
* feat(vault): pagination, team filter, graph upgrade, and link_type param
- Add CountDocuments to VaultStore interface (PG + SQLite)
- Wrap vault list response as {documents, total} for pagination
- Add optional link_type param to vault_link tool (wikilink/reference)
- Fix resolveOrRegister to use inferVaultDocType instead of hardcoded "note"
- Add team filter dropdown and pagination UI (100/page) to vault page
- Rewrite vault graph with KG-level features: zoom controls, node limit
selector, click highlight/dim, double-click detail, link labels, stats bar
- Decouple graph data fetch from table (independent limit 500)
- Update VaultDocument type with team_id, summary, custom_scope, media
* fix(vault): graph not rendering due to containerRef timing issue
The early return for loading state prevented containerRef from mounting,
so useLayoutEffect and ResizeObserver never captured dimensions. Moved
loading/empty states inside the container div so the ref always exists.
* fix(vault): eliminate graph flicker on zoom by using ref instead of state
onZoom fired setZoomLevel on every frame → React re-render → new inline
callback references → ForceGraph2D flickered. Now zoom level is stored
in a ref and the display is updated via direct DOM mutation, avoiding
re-renders during continuous zoom interactions.
* refactor(backend): comprehensive audit — safety, god files, interfaces, BaseChannel, tests, benchmarks
Phase 1: ExportTokenStore with lifecycle management (replaces leaked globals)
Phase 2: Split 4 god files (agents_export, agents_import, openai, loop_history) into 17 focused files
Phase 3: Add testability interfaces (consolidation EntityExtractor, heartbeat ProviderResolver/EventPublisher/ActiveSessionChecker)
Phase 4: Consolidate 6 duplicated fields + policy/pairing logic into BaseChannel, migrate all 8 channels
Phase 5: 57 new unit tests (i18n, edition, channels/policy, consolidation, heartbeat) + CI coverage
Phase 6: 30 benchmark tests (tokencount, skills BM25, tool registry, agent loop)
Phase 7: Context propagation fix + 6 metadata key constants
69 files changed, +7361/-3828 (net -3050 lines removed)
* fix(vault): fetch graph links per-agent so all-agents mode shows links
useVaultAllLinks required a single agentId, so links never loaded in
all-agents mode (agentId=""). Replaced with inline per-agent fetching
that groups documents by agent_id and fetches links for each agent.
* docs: add vault enhancement changelog entry
* feat(agent): add displayName to Loop and SystemPrompt for runtime context
Pass agent display name through LoopConfig and SystemPromptConfig so
the runtime section can show a human-readable agent name.
* fix(vault): truncate path from head and hash from middle in detail dialog
Path now uses dir=rtl so ellipsis appears at the start, keeping the
meaningful filename visible. SHA-256 hash shows first 8 + last 8 chars
with ellipsis in the middle instead of truncating the tail.
---------
Co-authored-by: Plateau Nguyen <nguyennlt.ncc@gmail.com>
Co-authored-by: Kai (Tam Nhu) Tran <61256810+kaitranntt@users.noreply.github.com>
This commit is contained in:
866 files changed
+73165
-11578
No files matched your search
+170
-233
@@ -3,13 +3,11 @@ package cmd
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
func agentCmd() *cobra.Command {
|
||||
@@ -26,96 +24,77 @@ func agentCmd() *cobra.Command {
|
||||
|
||||
// --- agent list ---
|
||||
|
||||
// httpAgent is the CLI-side representation of an agent from the HTTP API.
|
||||
type httpAgent struct {
|
||||
ID string `json:"id"`
|
||||
AgentKey string `json:"agent_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AgentType string `json:"agent_type"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
func agentListCmd() *cobra.Command {
|
||||
var jsonOutput bool
|
||||
var agentType string
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all configured agents",
|
||||
Short: "List all agents (requires running gateway)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runAgentList(jsonOutput)
|
||||
requireRunningGatewayHTTP()
|
||||
runAgentList(jsonOutput, agentType)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
|
||||
cmd.Flags().StringVar(&agentType, "type", "", "filter by agent type (open|predefined)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type agentListEntry struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Workspace string `json:"workspace,omitempty"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
func runAgentList(jsonOutput bool) {
|
||||
cfgPath := resolveConfigPath()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
func runAgentList(jsonOutput bool, agentType string) {
|
||||
path := "/v1/agents"
|
||||
resp, err := gatewayHTTPGet(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var entries []agentListEntry
|
||||
|
||||
// Default agent (always present)
|
||||
d := cfg.Agents.Defaults
|
||||
defaultID := cfg.ResolveDefaultAgentID()
|
||||
entries = append(entries, agentListEntry{
|
||||
ID: config.DefaultAgentID,
|
||||
DisplayName: cfg.ResolveDisplayName(config.DefaultAgentID),
|
||||
Provider: d.Provider,
|
||||
Model: d.Model,
|
||||
Workspace: d.Workspace,
|
||||
IsDefault: defaultID == config.DefaultAgentID,
|
||||
})
|
||||
|
||||
// Agents from list
|
||||
ids := make([]string, 0, len(cfg.Agents.List))
|
||||
for id := range cfg.Agents.List {
|
||||
if id == config.DefaultAgentID {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
// Parse agents array from response
|
||||
raw, _ := json.Marshal(resp["agents"])
|
||||
var agents []httpAgent
|
||||
if err := json.Unmarshal(raw, &agents); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing agent list: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
for _, id := range ids {
|
||||
resolved := cfg.ResolveAgent(id)
|
||||
spec := cfg.Agents.List[id]
|
||||
name := spec.DisplayName
|
||||
if name == "" {
|
||||
name = id
|
||||
// Apply type filter
|
||||
if agentType != "" {
|
||||
var filtered []httpAgent
|
||||
for _, a := range agents {
|
||||
if a.AgentType == agentType {
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
}
|
||||
entries = append(entries, agentListEntry{
|
||||
ID: id,
|
||||
DisplayName: name,
|
||||
Provider: resolved.Provider,
|
||||
Model: resolved.Model,
|
||||
Workspace: resolved.Workspace,
|
||||
IsDefault: id == defaultID,
|
||||
})
|
||||
agents = filtered
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
data, _ := json.MarshalIndent(entries, "", " ")
|
||||
data, _ := json.MarshalIndent(agents, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No agents configured.")
|
||||
if len(agents) == 0 {
|
||||
fmt.Println("No agents found.")
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tDISPLAY NAME\tPROVIDER\tMODEL\tDEFAULT")
|
||||
for _, e := range entries {
|
||||
def := ""
|
||||
if e.IsDefault {
|
||||
def = "*"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.ID, e.DisplayName, e.Provider, e.Model, def)
|
||||
fmt.Fprintln(w, "KEY\tDISPLAY NAME\tTYPE\tPROVIDER\tMODEL\tSTATUS")
|
||||
for _, a := range agents {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
a.AgentKey, a.DisplayName, a.AgentType, a.Provider, a.Model, a.Status)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
@@ -125,154 +104,168 @@ func runAgentList(jsonOutput bool) {
|
||||
func agentAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new agent (interactive wizard)",
|
||||
Short: "Add a new agent (interactive, requires running gateway)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runAgentAdd()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentAdd() {
|
||||
cfgPath := resolveConfigPath()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
// Start with default config if no file exists
|
||||
if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) {
|
||||
cfg = config.Default()
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
// httpProvider is the CLI-side representation of a provider from the HTTP API.
|
||||
type httpProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProviderType string `json:"provider_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// httpProviderModel is a model entry from a provider's model list.
|
||||
type httpProviderModel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
func runAgentAdd() {
|
||||
fmt.Println("── Add New Agent ──")
|
||||
fmt.Println()
|
||||
|
||||
// Step 1: Agent name (with validation loop)
|
||||
var name string
|
||||
for {
|
||||
name, err = promptString("Agent name", "e.g. coder, researcher, assistant", "")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
fmt.Println(" Name is required.")
|
||||
continue
|
||||
}
|
||||
id := config.NormalizeAgentID(name)
|
||||
if id == config.DefaultAgentID {
|
||||
fmt.Printf(" %q is reserved.\n", config.DefaultAgentID)
|
||||
continue
|
||||
}
|
||||
if _, exists := cfg.Agents.List[id]; exists {
|
||||
fmt.Printf(" Agent %q already exists.\n", id)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
agentID := config.NormalizeAgentID(name)
|
||||
if name != agentID {
|
||||
fmt.Printf(" Normalized ID: %s\n", agentID)
|
||||
// Step 1: Agent key
|
||||
agentKey, err := promptString("Agent key (slug)", "e.g. coder, researcher, assistant", "")
|
||||
if err != nil || agentKey == "" {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Display name
|
||||
displayName, err := promptString("Display name", "", name)
|
||||
displayName, err := promptString("Display name", "", agentKey)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Provider (optional override)
|
||||
providerOptions := []SelectOption[string]{
|
||||
{fmt.Sprintf("Inherit from defaults (%s)", cfg.Agents.Defaults.Provider), ""},
|
||||
{"OpenRouter", "openrouter"},
|
||||
{"Anthropic", "anthropic"},
|
||||
{"OpenAI", "openai"},
|
||||
{"Groq", "groq"},
|
||||
{"DeepSeek", "deepseek"},
|
||||
{"Gemini", "gemini"},
|
||||
{"Mistral", "mistral"},
|
||||
// Step 3: Agent type
|
||||
typeOptions := []SelectOption[string]{
|
||||
{"Open (per-user context)", "open"},
|
||||
{"Predefined (shared context)", "predefined"},
|
||||
}
|
||||
|
||||
providerChoice, err := promptSelect("Provider", providerOptions, 0)
|
||||
agentType, err := promptSelect("Agent type", typeOptions, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: Model (optional override)
|
||||
modelPlaceholder := fmt.Sprintf("(inherit: %s)", cfg.Agents.Defaults.Model)
|
||||
model, err := promptString("Model (empty = inherit from defaults)", modelPlaceholder, "")
|
||||
// Step 4: Provider (fetched from gateway)
|
||||
providers, err := fetchProviders()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching providers: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
fmt.Println("No providers configured. Run 'goclaw providers add' first.")
|
||||
return
|
||||
}
|
||||
|
||||
providerOptions := make([]SelectOption[string], len(providers))
|
||||
for i, p := range providers {
|
||||
label := fmt.Sprintf("%s (%s)", p.Name, p.ProviderType)
|
||||
providerOptions[i] = SelectOption[string]{Label: label, Value: p.ID}
|
||||
}
|
||||
providerID, err := promptSelect("Provider", providerOptions, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 5: Workspace
|
||||
defaultWS := fmt.Sprintf("%s/%s", cfg.Agents.Defaults.Workspace, agentID)
|
||||
workspace, err := promptString("Workspace directory", "", defaultWS)
|
||||
// Step 5: Model (fetched from selected provider)
|
||||
model, err := selectModel(providerID)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build AgentSpec
|
||||
spec := config.AgentSpec{
|
||||
DisplayName: displayName,
|
||||
Provider: providerChoice,
|
||||
Model: model,
|
||||
Workspace: workspace,
|
||||
// Create agent via HTTP API
|
||||
body := map[string]any{
|
||||
"agent_key": agentKey,
|
||||
"display_name": displayName,
|
||||
"agent_type": agentType,
|
||||
"provider": findProviderType(providers, providerID),
|
||||
"model": model,
|
||||
}
|
||||
|
||||
// Add to config
|
||||
if cfg.Agents.List == nil {
|
||||
cfg.Agents.List = make(map[string]config.AgentSpec)
|
||||
}
|
||||
cfg.Agents.List[agentID] = spec
|
||||
|
||||
// Create workspace directory
|
||||
expandedWS := config.ExpandHome(workspace)
|
||||
if err := os.MkdirAll(expandedWS, 0755); err != nil {
|
||||
fmt.Printf("Warning: could not create workspace: %v\n", err)
|
||||
}
|
||||
|
||||
// Save config (strip secrets like onboard does)
|
||||
savedProviders := cfg.Providers
|
||||
savedGwToken := cfg.Gateway.Token
|
||||
savedTgToken := cfg.Channels.Telegram.Token
|
||||
cfg.Providers = config.ProvidersConfig{}
|
||||
cfg.Gateway.Token = ""
|
||||
cfg.Channels.Telegram.Token = ""
|
||||
|
||||
saveErr := config.Save(cfgPath, cfg)
|
||||
|
||||
cfg.Providers = savedProviders
|
||||
cfg.Gateway.Token = savedGwToken
|
||||
cfg.Channels.Telegram.Token = savedTgToken
|
||||
|
||||
if saveErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving config: %v\n", saveErr)
|
||||
_, err = gatewayHTTPPost("/v1/agents", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Agent %q created successfully.\n", agentID)
|
||||
fmt.Printf(" Display name: %s\n", displayName)
|
||||
if providerChoice != "" {
|
||||
fmt.Printf(" Provider: %s\n", providerChoice)
|
||||
} else {
|
||||
fmt.Printf(" Provider: (inherit: %s)\n", cfg.Agents.Defaults.Provider)
|
||||
fmt.Printf("Agent %q created successfully.\n", agentKey)
|
||||
fmt.Printf(" Type: %s\n", agentType)
|
||||
fmt.Printf(" Model: %s\n", model)
|
||||
}
|
||||
|
||||
// fetchProviders returns the list of providers from the gateway.
|
||||
func fetchProviders() ([]httpProvider, error) {
|
||||
resp, err := gatewayHTTPGet("/v1/providers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if model != "" {
|
||||
fmt.Printf(" Model: %s\n", model)
|
||||
} else {
|
||||
fmt.Printf(" Model: (inherit: %s)\n", cfg.Agents.Defaults.Model)
|
||||
raw, _ := json.Marshal(resp["providers"])
|
||||
var providers []httpProvider
|
||||
if err := json.Unmarshal(raw, &providers); err != nil {
|
||||
return nil, fmt.Errorf("parse providers: %w", err)
|
||||
}
|
||||
fmt.Printf(" Workspace: %s\n", workspace)
|
||||
fmt.Println()
|
||||
fmt.Println("Restart the gateway to activate this agent.")
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// selectModel fetches models from a provider and prompts the user to pick one.
|
||||
func selectModel(providerID string) (string, error) {
|
||||
resp, err := gatewayHTTPGet("/v1/providers/" + url.PathEscape(providerID) + "/models")
|
||||
if err != nil {
|
||||
// Fallback: manual model input if provider doesn't support model listing
|
||||
model, promptErr := promptString("Model name", "e.g. claude-sonnet-4-20250514", "")
|
||||
if promptErr != nil || model == "" {
|
||||
return "", fmt.Errorf("cancelled")
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(resp["models"])
|
||||
var models []httpProviderModel
|
||||
if err := json.Unmarshal(raw, &models); err != nil || len(models) == 0 {
|
||||
// Fallback to manual input
|
||||
model, promptErr := promptString("Model name", "e.g. claude-sonnet-4-20250514", "")
|
||||
if promptErr != nil || model == "" {
|
||||
return "", fmt.Errorf("cancelled")
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
options := make([]SelectOption[string], len(models))
|
||||
for i, m := range models {
|
||||
label := m.ID
|
||||
if m.Name != "" && m.Name != m.ID {
|
||||
label = fmt.Sprintf("%s (%s)", m.ID, m.Name)
|
||||
}
|
||||
options[i] = SelectOption[string]{Label: label, Value: m.ID}
|
||||
}
|
||||
|
||||
selected, err := promptSelect("Model", options, 0)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cancelled")
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
// findProviderType returns the provider_type for a given provider ID.
|
||||
func findProviderType(providers []httpProvider, id string) string {
|
||||
for _, p := range providers {
|
||||
if p.ID == id {
|
||||
return p.ProviderType
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- agent delete ---
|
||||
@@ -281,9 +274,10 @@ func agentDeleteCmd() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <agent-id>",
|
||||
Short: "Delete an agent",
|
||||
Short: "Delete an agent (requires running gateway)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runAgentDelete(args[0], force)
|
||||
},
|
||||
}
|
||||
@@ -291,26 +285,7 @@ func agentDeleteCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runAgentDelete(rawID string, force bool) {
|
||||
agentID := config.NormalizeAgentID(rawID)
|
||||
|
||||
if agentID == config.DefaultAgentID {
|
||||
fmt.Fprintf(os.Stderr, "Error: %q cannot be deleted (reserved).\n", config.DefaultAgentID)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfgPath := resolveConfigPath()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, exists := cfg.Agents.List[agentID]; !exists {
|
||||
fmt.Fprintf(os.Stderr, "Error: agent %q not found.\n", agentID)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func runAgentDelete(agentID string, force bool) {
|
||||
if !force {
|
||||
confirmed, err := promptConfirm(fmt.Sprintf("Delete agent %q?", agentID), false)
|
||||
if err != nil || !confirmed {
|
||||
@@ -319,48 +294,10 @@ func runAgentDelete(rawID string, force bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove agent
|
||||
delete(cfg.Agents.List, agentID)
|
||||
|
||||
// Remove bindings that reference this agent
|
||||
removedBindings := 0
|
||||
if len(cfg.Bindings) > 0 {
|
||||
filtered := make([]config.AgentBinding, 0, len(cfg.Bindings))
|
||||
for _, b := range cfg.Bindings {
|
||||
if config.NormalizeAgentID(b.AgentID) == agentID {
|
||||
removedBindings++
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, b)
|
||||
}
|
||||
cfg.Bindings = filtered
|
||||
if len(cfg.Bindings) == 0 {
|
||||
cfg.Bindings = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Save config (strip secrets)
|
||||
savedProviders := cfg.Providers
|
||||
savedGwToken := cfg.Gateway.Token
|
||||
savedTgToken := cfg.Channels.Telegram.Token
|
||||
cfg.Providers = config.ProvidersConfig{}
|
||||
cfg.Gateway.Token = ""
|
||||
cfg.Channels.Telegram.Token = ""
|
||||
|
||||
saveErr := config.Save(cfgPath, cfg)
|
||||
|
||||
cfg.Providers = savedProviders
|
||||
cfg.Gateway.Token = savedGwToken
|
||||
cfg.Channels.Telegram.Token = savedTgToken
|
||||
|
||||
if saveErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving config: %v\n", saveErr)
|
||||
if err := gatewayHTTPDelete("/v1/agents/" + url.PathEscape(agentID)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error deleting agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Agent %q deleted.\n", agentID)
|
||||
if removedBindings > 0 {
|
||||
fmt.Printf("Removed %d binding(s) that referenced this agent.\n", removedBindings)
|
||||
}
|
||||
fmt.Println("Restart the gateway to apply changes.")
|
||||
}
|
||||
+2
-50
@@ -1,12 +1,8 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/oauth"
|
||||
@@ -24,54 +20,10 @@ func authCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// gatewayURL returns the base URL for the running gateway.
|
||||
func gatewayURL() string {
|
||||
if u := os.Getenv("GOCLAW_GATEWAY_URL"); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
host := os.Getenv("GOCLAW_HOST")
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := os.Getenv("GOCLAW_PORT")
|
||||
if port == "" {
|
||||
port = "3577"
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%s", host, port)
|
||||
}
|
||||
|
||||
// gatewayRequest sends an authenticated request to the running gateway.
|
||||
// Delegates to the shared HTTP client in gateway_http_client.go.
|
||||
func gatewayRequest(method, path string) (map[string]any, error) {
|
||||
url := gatewayURL() + path
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if token := os.Getenv("GOCLAW_TOKEN"); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach gateway at %s: %w", gatewayURL(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("invalid response from gateway: %s", string(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
if msg, ok := result["error"].(string); ok {
|
||||
return nil, fmt.Errorf("gateway error: %s", msg)
|
||||
}
|
||||
return nil, fmt.Errorf("gateway returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return gatewayHTTPDo(method, path, nil)
|
||||
}
|
||||
|
||||
func authStatusCmd() *cobra.Command {
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/backup"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
func backupCmd() *cobra.Command {
|
||||
var (
|
||||
outputPath string
|
||||
excludeDB bool
|
||||
excludeFiles bool
|
||||
uploadS3 bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "backup",
|
||||
Short: "Create a full system backup (database + filesystem)",
|
||||
Long: "Produces a .tar.gz archive containing a pg_dump of the database and all workspace/data files.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(resolveConfigPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
dsn := cfg.Database.PostgresDSN
|
||||
|
||||
if outputPath == "" {
|
||||
ts := time.Now().UTC().Format("20060102-150405")
|
||||
outputPath = fmt.Sprintf("./backup-%s.tar.gz", ts)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting backup → %s\n", outputPath)
|
||||
if excludeDB {
|
||||
fmt.Println(" database: excluded")
|
||||
}
|
||||
if excludeFiles {
|
||||
fmt.Println(" filesystem: excluded")
|
||||
}
|
||||
|
||||
opts := backup.Options{
|
||||
DSN: dsn,
|
||||
DataDir: cfg.ResolvedDataDir(),
|
||||
WorkspacePath: cfg.WorkspacePath(),
|
||||
OutputPath: outputPath,
|
||||
CreatedBy: "cli",
|
||||
GoclawVersion: Version,
|
||||
ExcludeDB: excludeDB,
|
||||
ExcludeFiles: excludeFiles,
|
||||
ProgressFn: func(phase, detail string) {
|
||||
fmt.Printf(" [%s] %s\n", phase, detail)
|
||||
},
|
||||
}
|
||||
|
||||
manifest, err := backup.Run(cmd.Context(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nBackup complete: %s\n", outputPath)
|
||||
fmt.Printf(" schema version : %d\n", manifest.SchemaVersion)
|
||||
fmt.Printf(" database size : %d MB\n", manifest.Stats.DatabaseSizeBytes>>20)
|
||||
fmt.Printf(" filesystem : %d files, %d MB\n",
|
||||
manifest.Stats.FilesystemFiles,
|
||||
manifest.Stats.FilesystemBytes>>20,
|
||||
)
|
||||
fmt.Printf(" total : %d MB\n", manifest.Stats.TotalBytes>>20)
|
||||
|
||||
if uploadS3 {
|
||||
if err := uploadBackupToS3(cmd.Context(), cfg, outputPath, Version); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nS3 upload failed: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output path for .tar.gz (default: ./backup-<timestamp>.tar.gz)")
|
||||
cmd.Flags().BoolVar(&excludeDB, "exclude-db", false, "skip database dump (filesystem only)")
|
||||
cmd.Flags().BoolVar(&excludeFiles, "exclude-files", false, "skip filesystem archive (database only)")
|
||||
cmd.Flags().BoolVar(&uploadS3, "upload-s3", false, "upload backup to S3 after creation (requires s3 config in config_secrets)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// uploadBackupToS3 loads S3 config from the database and uploads the archive.
|
||||
func uploadBackupToS3(ctx context.Context, cfg *config.Config, archivePath, version string) error {
|
||||
if cfg.Database.PostgresDSN == "" {
|
||||
return fmt.Errorf("postgres DSN not configured; set GOCLAW_POSTGRES_DSN")
|
||||
}
|
||||
db, err := sql.Open("pgx", cfg.Database.PostgresDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
|
||||
secrets := pg.NewPGConfigSecretsStore(db, encKey)
|
||||
s3cfg, err := backup.LoadS3Config(ctx, secrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load s3 config: %w", err)
|
||||
}
|
||||
if s3cfg == nil {
|
||||
return fmt.Errorf("s3 not configured — run: goclaw s3-config set")
|
||||
}
|
||||
|
||||
client, err := backup.NewS3Client(s3cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create s3 client: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open archive: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat archive: %w", err)
|
||||
}
|
||||
|
||||
ts := time.Now().UTC().Format("20060102-150405")
|
||||
key := fmt.Sprintf("backup-%s-v%s.tar.gz", ts, version)
|
||||
if version == "" {
|
||||
key = fmt.Sprintf("backup-%s.tar.gz", ts)
|
||||
}
|
||||
|
||||
fmt.Printf("\nUploading to S3: %s/%s ...\n", s3cfg.Bucket, key)
|
||||
if err := client.Upload(ctx, key, f, info.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("S3 upload complete: s3://%s/%s%s\n", s3cfg.Bucket, s3cfg.Prefix, key)
|
||||
return nil
|
||||
}
|
||||
+181
-25
@@ -3,64 +3,71 @@ package cmd
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
func channelsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "channels",
|
||||
Short: "List and manage messaging channels",
|
||||
Short: "Manage messaging channels (requires running gateway)",
|
||||
}
|
||||
cmd.AddCommand(channelsListCmd())
|
||||
cmd.AddCommand(channelsAddCmd())
|
||||
cmd.AddCommand(channelsDeleteCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
type channelEntry struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasCredentials bool `json:"hasCredentials"`
|
||||
// httpChannelInstance is the CLI-side representation of a channel instance from the HTTP API.
|
||||
type httpChannelInstance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func channelsListCmd() *cobra.Command {
|
||||
var jsonOutput bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured channels and their status",
|
||||
Short: "List channel instances",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cfgPath := resolveConfigPath()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
requireRunningGatewayHTTP()
|
||||
|
||||
resp, err := gatewayHTTPGet("/v1/channels/instances")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
entries := []channelEntry{
|
||||
{"telegram", cfg.Channels.Telegram.Enabled, cfg.Channels.Telegram.Token != ""},
|
||||
{"discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != ""},
|
||||
{"zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != ""},
|
||||
{"feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != ""},
|
||||
{"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled},
|
||||
raw, _ := json.Marshal(resp["instances"])
|
||||
var instances []httpChannelInstance
|
||||
if err := json.Unmarshal(raw, &instances); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
data, _ := json.MarshalIndent(entries, "", " ")
|
||||
data, _ := json.MarshalIndent(instances, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No channel instances configured.")
|
||||
return
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, "CHANNEL\tENABLED\tCREDENTIALS\n")
|
||||
for _, e := range entries {
|
||||
creds := "missing"
|
||||
if e.HasCredentials {
|
||||
creds = "ok"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%v\t%s\n", e.Name, e.Enabled, creds)
|
||||
fmt.Fprintf(tw, "ID\tNAME\tTYPE\tENABLED\tSTATUS\n")
|
||||
for _, inst := range instances {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%v\t%s\n",
|
||||
inst.ID, inst.Name, inst.ChannelType, inst.Enabled, inst.Status)
|
||||
}
|
||||
tw.Flush()
|
||||
},
|
||||
@@ -68,3 +75,152 @@ func channelsListCmd() *cobra.Command {
|
||||
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func channelsAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new channel instance (interactive)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runChannelsAdd()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runChannelsAdd() {
|
||||
fmt.Println("── Add Channel Instance ──")
|
||||
fmt.Println()
|
||||
|
||||
// Step 1: Channel type
|
||||
typeOptions := []SelectOption[string]{
|
||||
{"Telegram", "telegram"},
|
||||
{"Discord", "discord"},
|
||||
{"Slack", "slack"},
|
||||
}
|
||||
channelType, err := promptSelect("Channel type", typeOptions, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Name
|
||||
name, err := promptString("Instance name", "e.g. my-telegram-bot", channelType+"-bot")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Credentials per type
|
||||
creds := map[string]string{}
|
||||
switch channelType {
|
||||
case "telegram":
|
||||
token, err := promptPassword("Bot token", "from @BotFather")
|
||||
if err != nil || token == "" {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
creds["token"] = token
|
||||
case "discord":
|
||||
token, err := promptPassword("Bot token", "from Discord Developer Portal")
|
||||
if err != nil || token == "" {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
creds["token"] = token
|
||||
case "slack":
|
||||
token, err := promptPassword("Bot token", "xoxb-...")
|
||||
if err != nil || token == "" {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
creds["token"] = token
|
||||
secret, err := promptPassword("Signing secret", "from Slack app settings")
|
||||
if err != nil || secret == "" {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
creds["signing_secret"] = secret
|
||||
}
|
||||
|
||||
// Step 4: Bind to agent
|
||||
agents, err := fetchAgentList()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching agents: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
fmt.Println("No agents found. Create an agent first with 'goclaw agent add'.")
|
||||
return
|
||||
}
|
||||
|
||||
agentOptions := make([]SelectOption[string], len(agents))
|
||||
for i, a := range agents {
|
||||
agentOptions[i] = SelectOption[string]{
|
||||
Label: fmt.Sprintf("%s (%s)", a.AgentKey, a.DisplayName),
|
||||
Value: a.ID,
|
||||
}
|
||||
}
|
||||
agentID, err := promptSelect("Bind to agent", agentOptions, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Create via HTTP API
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"channel_type": channelType,
|
||||
"agent_id": agentID,
|
||||
"enabled": true,
|
||||
"credentials": creds,
|
||||
}
|
||||
|
||||
_, err = gatewayHTTPPost("/v1/channels/instances", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating channel: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\nChannel %q (%s) created and bound to agent.\n", name, channelType)
|
||||
fmt.Println("Note: For Zalo, Feishu, WhatsApp — use the Web Dashboard.")
|
||||
}
|
||||
|
||||
func channelsDeleteCmd() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a channel instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
if !force {
|
||||
confirmed, err := promptConfirm(fmt.Sprintf("Delete channel %q?", args[0]), false)
|
||||
if err != nil || !confirmed {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := gatewayHTTPDelete("/v1/channels/instances/" + url.PathEscape(args[0])); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Channel %q deleted.\n", args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&force, "force", false, "skip confirmation")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fetchAgentList returns agents from the gateway for use in selection prompts.
|
||||
func fetchAgentList() ([]httpAgent, error) {
|
||||
resp, err := gatewayHTTPGet("/v1/agents")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, _ := json.Marshal(resp["agents"])
|
||||
var agents []httpAgent
|
||||
if err := json.Unmarshal(raw, &agents); err != nil {
|
||||
return nil, fmt.Errorf("parse agents: %w", err)
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
+11
-14
@@ -1,23 +1,20 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
import "net/http"
|
||||
|
||||
// requireGateway exits with a helpful error if the gateway is not reachable.
|
||||
// Uses HTTP /health endpoint (faster and doesn't require WS handshake).
|
||||
func requireGateway() {
|
||||
if !isGatewayReachable() {
|
||||
fmt.Fprintln(os.Stderr, "Error: the gateway must be running for this command.")
|
||||
fmt.Fprintln(os.Stderr, "Start it first: goclaw")
|
||||
os.Exit(1)
|
||||
}
|
||||
requireRunningGatewayHTTP()
|
||||
}
|
||||
|
||||
// isGatewayReachable tries a quick RPC ping to check if the gateway is up.
|
||||
// isGatewayReachable checks if the gateway is up via HTTP health endpoint.
|
||||
func isGatewayReachable() bool {
|
||||
_, err := gatewayRPC("ping", nil)
|
||||
// Any response (even error) means the gateway is up.
|
||||
// Only connection failure means it's down.
|
||||
return err == nil
|
||||
base := resolveGatewayBaseURL()
|
||||
resp, err := healthClient.Get(base + "/health")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
+159
-848
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,276 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// TestBuildMergedAnnounceContent_SingleSuccess tests single completed task announcement.
|
||||
func TestBuildMergedAnnounceContent_SingleSuccess(t *testing.T) {
|
||||
entries := []announceEntry{
|
||||
{
|
||||
MemberAgent: "researcher",
|
||||
MemberDisplayName: "Nhà Nghiên Cứu",
|
||||
Content: "Found 5 relevant papers",
|
||||
},
|
||||
}
|
||||
result := buildMergedAnnounceContent(entries, "", "")
|
||||
|
||||
if !strings.Contains(result, "[System Message]") {
|
||||
t.Error("result missing [System Message]")
|
||||
}
|
||||
if !strings.Contains(result, "Nhà Nghiên Cứu (researcher)") {
|
||||
t.Error("result missing member display name with agent key")
|
||||
}
|
||||
if !strings.Contains(result, "completed task") {
|
||||
t.Error("result missing 'completed task' text")
|
||||
}
|
||||
if !strings.Contains(result, "Found 5 relevant papers") {
|
||||
t.Error("result missing task content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMergedAnnounceContent_SingleFailed tests single failed task announcement.
|
||||
func TestBuildMergedAnnounceContent_SingleFailed(t *testing.T) {
|
||||
entries := []announceEntry{
|
||||
{
|
||||
MemberAgent: "reviewer",
|
||||
MemberDisplayName: "",
|
||||
Content: "[FAILED] Database connection timeout",
|
||||
},
|
||||
}
|
||||
result := buildMergedAnnounceContent(entries, "", "")
|
||||
|
||||
if !strings.Contains(result, "[System Message]") {
|
||||
t.Error("result missing [System Message]")
|
||||
}
|
||||
if !strings.Contains(result, "failed to complete task") {
|
||||
t.Error("result missing 'failed to complete task' text")
|
||||
}
|
||||
if !strings.Contains(result, "Database connection timeout") {
|
||||
t.Error("result missing error message")
|
||||
}
|
||||
if !strings.Contains(result, "team_tasks(action=\"retry\"") {
|
||||
t.Error("result missing retry suggestion")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMergedAnnounceContent_BatchMixed tests multiple tasks with mixed success/failure.
|
||||
func TestBuildMergedAnnounceContent_BatchMixed(t *testing.T) {
|
||||
entries := []announceEntry{
|
||||
{
|
||||
MemberAgent: "researcher",
|
||||
MemberDisplayName: "Researcher",
|
||||
Content: "Analysis complete",
|
||||
},
|
||||
{
|
||||
MemberAgent: "reviewer",
|
||||
MemberDisplayName: "Reviewer",
|
||||
Content: "[FAILED] Permission denied",
|
||||
},
|
||||
{
|
||||
MemberAgent: "writer",
|
||||
MemberDisplayName: "Writer",
|
||||
Content: "Draft ready",
|
||||
},
|
||||
}
|
||||
result := buildMergedAnnounceContent(entries, "", "")
|
||||
|
||||
if !strings.Contains(result, "2 task(s) completed, 1 task(s) failed") {
|
||||
t.Error("result missing batch summary with counts")
|
||||
}
|
||||
if !strings.Contains(result, "Analysis complete") {
|
||||
t.Error("result missing first success content")
|
||||
}
|
||||
if !strings.Contains(result, "Permission denied") {
|
||||
t.Error("result missing failure content")
|
||||
}
|
||||
if !strings.Contains(result, "Draft ready") {
|
||||
t.Error("result missing second success content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMergedAnnounceContent_WithSnapshot tests annotation with task board snapshot.
|
||||
func TestBuildMergedAnnounceContent_WithSnapshot(t *testing.T) {
|
||||
entries := []announceEntry{
|
||||
{
|
||||
MemberAgent: "agent1",
|
||||
MemberDisplayName: "Agent One",
|
||||
Content: "Task done",
|
||||
},
|
||||
}
|
||||
snapshot := "Task Board:\n- Task 1: completed\n- Task 2: in progress"
|
||||
|
||||
result := buildMergedAnnounceContent(entries, snapshot, "")
|
||||
|
||||
if !strings.Contains(result, snapshot) {
|
||||
t.Error("result missing task board snapshot")
|
||||
}
|
||||
if !strings.Contains(result, "Some tasks are still in progress") {
|
||||
t.Error("result missing progress acknowledgement message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMergedAnnounceContent_AllDone tests when all tasks are completed.
|
||||
func TestBuildMergedAnnounceContent_AllDone(t *testing.T) {
|
||||
entries := []announceEntry{
|
||||
{
|
||||
MemberAgent: "agent1",
|
||||
MemberDisplayName: "Agent One",
|
||||
Content: "Finished",
|
||||
},
|
||||
}
|
||||
snapshot := "Task Board:\nAll 3 tasks completed"
|
||||
|
||||
result := buildMergedAnnounceContent(entries, snapshot, "")
|
||||
|
||||
if !strings.Contains(result, "All tasks in this batch are completed") {
|
||||
t.Error("result missing 'All tasks completed' summary message")
|
||||
}
|
||||
if !strings.Contains(result, "comprehensive summary of ALL results") {
|
||||
t.Error("result missing summary instruction")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMergedSubagentAnnounce_Single tests single subagent completion.
|
||||
func TestBuildMergedSubagentAnnounce_Single(t *testing.T) {
|
||||
entries := []subagentAnnounceEntry{
|
||||
{
|
||||
Label: "Search Wikipedia",
|
||||
Status: "completed",
|
||||
Content: "Found article on neural networks",
|
||||
Runtime: 2500 * time.Millisecond,
|
||||
Iterations: 1,
|
||||
InputTokens: 500,
|
||||
OutputTokens: 250,
|
||||
},
|
||||
}
|
||||
roster := tools.SubagentRoster{}
|
||||
|
||||
result := buildMergedSubagentAnnounce(entries, roster)
|
||||
|
||||
if !strings.Contains(result, "[System Message]") {
|
||||
t.Error("result missing [System Message]")
|
||||
}
|
||||
if !strings.Contains(result, "Search Wikipedia") {
|
||||
t.Error("result missing task label")
|
||||
}
|
||||
if !strings.Contains(result, "completed successfully") {
|
||||
t.Error("result missing 'completed successfully' status")
|
||||
}
|
||||
if !strings.Contains(result, "Found article on neural networks") {
|
||||
t.Error("result missing task content")
|
||||
}
|
||||
if !strings.Contains(result, "2.5s") {
|
||||
t.Error("result missing runtime")
|
||||
}
|
||||
if !strings.Contains(result, "tokens 500 in / 250 out") {
|
||||
t.Error("result missing token counts")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMergedSubagentAnnounce_Batch tests multiple subagent results.
|
||||
func TestBuildMergedSubagentAnnounce_Batch(t *testing.T) {
|
||||
entries := []subagentAnnounceEntry{
|
||||
{
|
||||
Label: "Fetch Data",
|
||||
Status: "completed",
|
||||
Content: "Data retrieved successfully",
|
||||
Runtime: 1000 * time.Millisecond,
|
||||
Iterations: 1,
|
||||
InputTokens: 100,
|
||||
OutputTokens: 150,
|
||||
},
|
||||
{
|
||||
Label: "Validate Schema",
|
||||
Status: "completed",
|
||||
Content: "Schema is valid",
|
||||
Runtime: 500 * time.Millisecond,
|
||||
Iterations: 2,
|
||||
InputTokens: 80,
|
||||
OutputTokens: 120,
|
||||
},
|
||||
{
|
||||
Label: "Process Results",
|
||||
Status: "failed",
|
||||
Content: "Timeout error",
|
||||
Runtime: 3000 * time.Millisecond,
|
||||
Iterations: 1,
|
||||
InputTokens: 200,
|
||||
OutputTokens: 0,
|
||||
},
|
||||
}
|
||||
roster := tools.SubagentRoster{}
|
||||
|
||||
result := buildMergedSubagentAnnounce(entries, roster)
|
||||
|
||||
if !strings.Contains(result, "2 subagent task(s) completed, 1 failed") {
|
||||
t.Error("result missing batch summary with counts")
|
||||
}
|
||||
if !strings.Contains(result, "Task #1:") {
|
||||
t.Error("result missing task #1")
|
||||
}
|
||||
if !strings.Contains(result, "Task #2:") {
|
||||
t.Error("result missing task #2")
|
||||
}
|
||||
if !strings.Contains(result, "Task #3:") {
|
||||
t.Error("result missing task #3")
|
||||
}
|
||||
if !strings.Contains(result, "Fetch Data") {
|
||||
t.Error("result missing first task label")
|
||||
}
|
||||
if !strings.Contains(result, "Process Results") {
|
||||
t.Error("result missing failed task label")
|
||||
}
|
||||
if !strings.Contains(result, "failed") {
|
||||
t.Error("result missing failed status")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemberLabel_WithDisplayName tests member label formatting with display name.
|
||||
func TestMemberLabel_WithDisplayName(t *testing.T) {
|
||||
e := announceEntry{
|
||||
MemberAgent: "agent_key",
|
||||
MemberDisplayName: "Agent Display",
|
||||
}
|
||||
result := memberLabel(e)
|
||||
if result != "Agent Display (agent_key)" {
|
||||
t.Errorf("memberLabel = %q, want %q", result, "Agent Display (agent_key)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemberLabel_WithoutDisplayName tests member label formatting without display name.
|
||||
func TestMemberLabel_WithoutDisplayName(t *testing.T) {
|
||||
e := announceEntry{
|
||||
MemberAgent: "agent_key",
|
||||
MemberDisplayName: "",
|
||||
}
|
||||
result := memberLabel(e)
|
||||
if result != "agent_key" {
|
||||
t.Errorf("memberLabel = %q, want %q", result, "agent_key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMergedAnnounceContent_WithWorkspace tests workspace annotation in message.
|
||||
func TestBuildMergedAnnounceContent_WithWorkspace(t *testing.T) {
|
||||
entries := []announceEntry{
|
||||
{
|
||||
MemberAgent: "agent",
|
||||
MemberDisplayName: "Agent",
|
||||
Content: "Complete",
|
||||
},
|
||||
}
|
||||
workspace := "/shared/workspace"
|
||||
|
||||
result := buildMergedAnnounceContent(entries, "", workspace)
|
||||
|
||||
if !strings.Contains(result, "Team workspace") {
|
||||
t.Error("result missing 'Team workspace' annotation")
|
||||
}
|
||||
if !strings.Contains(result, workspace) {
|
||||
t.Error("result missing workspace path")
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
orch "github.com/nextlevelbuilder/goclaw/internal/orchestration"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
@@ -19,62 +19,19 @@ import (
|
||||
|
||||
// announceEntry holds one teammate completion result waiting to be announced.
|
||||
type announceEntry struct {
|
||||
MemberAgent string // agent key (e.g. "researcher")
|
||||
MemberDisplayName string // display name (e.g. "Nhà Nghiên Cứu"), empty if not set
|
||||
Content string
|
||||
Media []agent.MediaResult
|
||||
MemberAgent string // agent key (e.g. "researcher")
|
||||
MemberDisplayName string // display name (e.g. "Nhà Nghiên Cứu"), empty if not set
|
||||
Content string
|
||||
Media []agent.MediaResult
|
||||
}
|
||||
|
||||
// announceQueueState tracks the per-session announce queue.
|
||||
// Producer-consumer: multiple goroutines add entries, one loops to drain+announce.
|
||||
type announceQueueState struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
entries []announceEntry
|
||||
}
|
||||
// teamAnnounceQueue uses BatchQueue for producer-consumer synchronization.
|
||||
var teamAnnounceQueue orch.BatchQueue[announceEntry]
|
||||
|
||||
// announceQueues maps leadSessionKey → queue. Cleaned up when queue finishes.
|
||||
var announceQueues sync.Map
|
||||
|
||||
func getOrCreateAnnounceQueue(key string) *announceQueueState {
|
||||
v, _ := announceQueues.LoadOrStore(key, &announceQueueState{})
|
||||
return v.(*announceQueueState)
|
||||
}
|
||||
|
||||
// enqueueAnnounce adds a result to the queue. Returns (queue, isProcessor).
|
||||
// enqueueAnnounce adds a result to the queue. Returns isProcessor.
|
||||
// If isProcessor=true, the caller must run processAnnounceLoop.
|
||||
func enqueueAnnounce(key string, entry announceEntry) (*announceQueueState, bool) {
|
||||
q := getOrCreateAnnounceQueue(key)
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.entries = append(q.entries, entry)
|
||||
if q.running {
|
||||
return q, false
|
||||
}
|
||||
q.running = true
|
||||
return q, true
|
||||
}
|
||||
|
||||
func (q *announceQueueState) drain() []announceEntry {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
out := q.entries
|
||||
q.entries = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// tryFinish atomically checks for pending entries and marks the queue idle.
|
||||
// Returns true if the processor should exit (no pending entries).
|
||||
// Prevents TOCTOU race between hasPending() and finish().
|
||||
func (q *announceQueueState) tryFinish(key string) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.entries) > 0 {
|
||||
return false // more work arrived — keep processing
|
||||
}
|
||||
q.running = false
|
||||
announceQueues.Delete(key)
|
||||
return true
|
||||
func enqueueAnnounce(key string, entry announceEntry) bool {
|
||||
return teamAnnounceQueue.Enqueue(key, entry)
|
||||
}
|
||||
|
||||
// announceRouting holds the shared routing info captured by the first goroutine.
|
||||
@@ -98,7 +55,6 @@ type announceRouting struct {
|
||||
// Loops until queue is empty.
|
||||
func processAnnounceLoop(
|
||||
ctx context.Context,
|
||||
q *announceQueueState,
|
||||
r announceRouting,
|
||||
sched *scheduler.Scheduler,
|
||||
msgBus *bus.MessageBus,
|
||||
@@ -107,9 +63,9 @@ func processAnnounceLoop(
|
||||
cfg *config.Config,
|
||||
) {
|
||||
for {
|
||||
entries := q.drain()
|
||||
entries := teamAnnounceQueue.Drain(r.LeadSessionKey)
|
||||
if len(entries) == 0 {
|
||||
if q.tryFinish(r.LeadSessionKey) {
|
||||
if teamAnnounceQueue.TryFinish(r.LeadSessionKey) {
|
||||
return
|
||||
}
|
||||
continue // entries arrived between drain and tryFinish
|
||||
@@ -156,51 +112,52 @@ func processAnnounceLoop(
|
||||
req.ForwardMedia = nil
|
||||
}
|
||||
|
||||
// Inject post-turn tracker (leader may create new tasks during announce).
|
||||
ptd := tools.NewPendingTeamDispatch()
|
||||
defer ptd.ReleaseTeamLock() // ensure lock released even on panic
|
||||
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
|
||||
outCh := sched.Schedule(schedCtx, scheduler.LaneSubagent, req)
|
||||
outcome := <-outCh
|
||||
// Process batch in closure so defer is scoped per iteration (panic safety).
|
||||
func() {
|
||||
ptd := tools.NewPendingTeamDispatch()
|
||||
defer ptd.ReleaseTeamLock()
|
||||
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
|
||||
outCh := sched.Schedule(schedCtx, scheduler.LaneSubagent, req)
|
||||
outcome := <-outCh
|
||||
|
||||
ptd.ReleaseTeamLock()
|
||||
if postTurn != nil {
|
||||
for tid, tIDs := range ptd.Drain() {
|
||||
if err := postTurn.ProcessPendingTasks(ctx, tid, tIDs); err != nil {
|
||||
slog.Warn("post_turn(announce): failed", "team_id", tid, "error", err)
|
||||
ptd.ReleaseTeamLock()
|
||||
if postTurn != nil {
|
||||
for tid, tIDs := range ptd.Drain() {
|
||||
if err := postTurn.ProcessPendingTasks(ctx, tid, tIDs); err != nil {
|
||||
slog.Warn("post_turn(announce): failed", "team_id", tid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.Err != nil {
|
||||
slog.Error("teammate announce: lead run failed", "error", outcome.Err, "batch_size", len(entries))
|
||||
} else {
|
||||
isSilent := outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content)
|
||||
if !(isSilent && len(outcome.Result.Media) == 0) {
|
||||
out := outcome.Result.Content
|
||||
if isSilent {
|
||||
out = ""
|
||||
if outcome.Err != nil {
|
||||
slog.Error("teammate announce: lead run failed", "error", outcome.Err, "batch_size", len(entries))
|
||||
} else {
|
||||
isSilent := outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content)
|
||||
if !(isSilent && len(outcome.Result.Media) == 0) {
|
||||
out := outcome.Result.Content
|
||||
if isSilent {
|
||||
out = ""
|
||||
}
|
||||
outMsg := bus.OutboundMessage{
|
||||
Channel: r.OrigChannel,
|
||||
ChatID: r.OrigChatID,
|
||||
Content: out,
|
||||
Metadata: r.OutMeta,
|
||||
}
|
||||
appendMediaToOutbound(&outMsg, outcome.Result.Media)
|
||||
msgBus.PublishOutbound(outMsg)
|
||||
}
|
||||
outMsg := bus.OutboundMessage{
|
||||
Channel: r.OrigChannel,
|
||||
ChatID: r.OrigChatID,
|
||||
Content: out,
|
||||
Metadata: r.OutMeta,
|
||||
}
|
||||
appendMediaToOutbound(&outMsg, outcome.Result.Media)
|
||||
msgBus.PublishOutbound(outMsg)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("teammate announce: batch processed",
|
||||
"batch_size", len(entries), "session", r.LeadSessionKey)
|
||||
slog.Info("teammate announce: batch processed",
|
||||
"batch_size", len(entries), "session", r.LeadSessionKey)
|
||||
}()
|
||||
|
||||
// Loop back — tryFinish at top will exit when queue is truly empty.
|
||||
}
|
||||
}
|
||||
|
||||
// memberLabel returns a display-friendly name for announce messages.
|
||||
// Uses "DisplayName (agent_key)" if display name is set, otherwise just agent_key.
|
||||
func memberLabel(e announceEntry) string {
|
||||
if e.MemberDisplayName != "" {
|
||||
return fmt.Sprintf("%s (%s)", e.MemberDisplayName, e.MemberAgent)
|
||||
|
||||
@@ -262,3 +262,4 @@ func wireChannelEventSubscribers(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func handleSubagentAnnounce(
|
||||
}
|
||||
|
||||
// Enqueue into producer-consumer queue using tenant-scoped key from routing.
|
||||
q, isProcessor := enqueueSubagentAnnounce(queueKey, entry)
|
||||
isProcessor := enqueueSubagentAnnounce(queueKey, entry)
|
||||
if isProcessor {
|
||||
deps.BgWg.Add(1)
|
||||
go func() {
|
||||
@@ -137,7 +137,7 @@ func handleSubagentAnnounce(
|
||||
// Fetch live roster for merged announce context.
|
||||
roster := deps.SubagentMgr.RosterForParent(parentAgent)
|
||||
|
||||
processSubagentAnnounceLoop(ctx, q, routing, roster, deps.SubagentMgr, deps.Sched, deps.MsgBus, deps.Cfg)
|
||||
processSubagentAnnounceLoop(ctx, routing, roster, deps.SubagentMgr, deps.Sched, deps.MsgBus, deps.Cfg)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -290,52 +290,10 @@ func handleTeammateMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Determine announce content: success result or failure error.
|
||||
var announceContent string
|
||||
var announceMedia []agent.MediaResult
|
||||
if outcome.Err != nil {
|
||||
slog.Error("teammate message: agent run failed", "error", outcome.Err)
|
||||
errMsg := outcome.Err.Error()
|
||||
if len(errMsg) > 500 {
|
||||
errMsg = errMsg[:500] + "..."
|
||||
}
|
||||
announceContent = fmt.Sprintf("[FAILED] %s", errMsg)
|
||||
} else if outcome.Result == nil {
|
||||
slog.Warn("teammate message: nil result without error", "from", senderID)
|
||||
// Build announce content from outcome + task comments/attachments.
|
||||
announceContent, announceMedia, ok := buildTeammateAnnounce(ctx, outcome, senderID, inMeta, deps)
|
||||
if !ok {
|
||||
return
|
||||
} else if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
|
||||
slog.Info("teammate message: suppressed silent/empty reply", "from", senderID)
|
||||
return
|
||||
} else {
|
||||
announceContent = outcome.Result.Content
|
||||
announceMedia = outcome.Result.Media
|
||||
}
|
||||
|
||||
// Append member comments & attachments so leader sees them in the announce.
|
||||
if taskIDStr := inMeta[tools.MetaTeamTaskID]; taskIDStr != "" && deps.TeamStore != nil {
|
||||
if taskUUID, err := uuid.Parse(taskIDStr); err == nil {
|
||||
if comments, err := deps.TeamStore.ListRecentTaskComments(ctx, taskUUID, 5); err == nil && len(comments) > 0 {
|
||||
var parts []string
|
||||
for _, c := range comments {
|
||||
author := c.AgentKey
|
||||
if author == "" {
|
||||
author = "system"
|
||||
}
|
||||
text := c.Content
|
||||
if len([]rune(text)) > 500 {
|
||||
text = string([]rune(text)[:500]) + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("- [%s]: %s", author, text))
|
||||
}
|
||||
announceContent += "\n\n[Member notes]\n" + strings.Join(parts, "\n")
|
||||
}
|
||||
if attachments, err := deps.TeamStore.ListTaskAttachments(ctx, taskUUID); err == nil && len(attachments) > 0 {
|
||||
announceContent += "\n\n[Attached files in team workspace]"
|
||||
for _, a := range attachments {
|
||||
announceContent += "\n- " + filepath.Base(a.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Announce result (or failure) to lead agent via announce queue.
|
||||
@@ -345,27 +303,7 @@ func handleTeammateMessage(
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve lead agent.
|
||||
leadAgent := ""
|
||||
if cachedTeam != nil {
|
||||
if leadAg, err := deps.AgentStore.GetByID(ctx, cachedTeam.LeadAgentID); err == nil {
|
||||
leadAgent = leadAg.AgentKey
|
||||
}
|
||||
} else if teamIDStr := inMeta[tools.MetaTeamID]; teamIDStr != "" {
|
||||
if teamUUID, err := uuid.Parse(teamIDStr); err == nil {
|
||||
if team, err := deps.TeamStore.GetTeam(ctx, teamUUID); err == nil {
|
||||
if leadAg, err := deps.AgentStore.GetByID(ctx, team.LeadAgentID); err == nil {
|
||||
leadAgent = leadAg.AgentKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if leadAgent == "" {
|
||||
leadAgent = inMeta[tools.MetaFromAgent]
|
||||
}
|
||||
if leadAgent == "" {
|
||||
leadAgent = deps.Cfg.ResolveDefaultAgentID()
|
||||
}
|
||||
leadAgent := resolveTeammateLeadAgent(ctx, cachedTeam, inMeta, deps)
|
||||
|
||||
origPeerKind := inMeta[tools.MetaOriginPeerKind]
|
||||
if origPeerKind == "" {
|
||||
@@ -401,7 +339,7 @@ func handleTeammateMessage(
|
||||
Content: announceContent,
|
||||
Media: announceMedia,
|
||||
}
|
||||
q, isProcessor := enqueueAnnounce(leadSessionKey, entry)
|
||||
isProcessor := enqueueAnnounce(leadSessionKey, entry)
|
||||
if !isProcessor {
|
||||
slog.Info("teammate announce: merged into pending batch",
|
||||
"member", entry.MemberAgent, "session", leadSessionKey)
|
||||
@@ -423,7 +361,7 @@ func handleTeammateMessage(
|
||||
ParentRootSpanID: parentRootSpanID,
|
||||
OutMeta: outMeta,
|
||||
}
|
||||
processAnnounceLoop(ctx, q, routing, deps.Sched, deps.MsgBus, deps.TeamStore, deps.PostTurn, deps.Cfg)
|
||||
processAnnounceLoop(ctx, routing, deps.Sched, deps.MsgBus, deps.TeamStore, deps.PostTurn, deps.Cfg)
|
||||
}(origChannel, origChatID, msg.SenderID, taskIDStr, outMeta, msg.Metadata)
|
||||
|
||||
return true
|
||||
@@ -491,9 +429,9 @@ func handleStopCommand(
|
||||
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
|
||||
}
|
||||
}
|
||||
if msg.Metadata["dm_thread_id"] != "" && peerKind == string(sessions.PeerDirect) {
|
||||
if msg.Metadata[tools.MetaDMThreadID] != "" && peerKind == string(sessions.PeerDirect) {
|
||||
var threadID int
|
||||
fmt.Sscanf(msg.Metadata["dm_thread_id"], "%d", &threadID)
|
||||
fmt.Sscanf(msg.Metadata[tools.MetaDMThreadID], "%d", &threadID)
|
||||
if threadID > 0 {
|
||||
sessionKey = sessions.BuildDMThreadSessionKey(agentID, msg.Channel, msg.ChatID, threadID)
|
||||
}
|
||||
@@ -580,3 +518,78 @@ func buildTaskBoardSnapshot(ctx context.Context, teamStore store.TeamStore, team
|
||||
return fmt.Sprintf("=== Task board (this batch) ===\nTask progress: %d/%d completed, %d active:\n%s",
|
||||
completed, total, active, strings.Join(activeLines, "\n"))
|
||||
}
|
||||
|
||||
// buildTeammateAnnounce constructs announce content from agent outcome + task comments/attachments.
|
||||
// Returns content, media, and whether to proceed with the announce.
|
||||
func buildTeammateAnnounce(ctx context.Context, outcome scheduler.RunOutcome, senderID string, inMeta map[string]string, deps *ConsumerDeps) (string, []agent.MediaResult, bool) {
|
||||
var content string
|
||||
var media []agent.MediaResult
|
||||
|
||||
if outcome.Err != nil {
|
||||
slog.Error("teammate message: agent run failed", "error", outcome.Err)
|
||||
errMsg := outcome.Err.Error()
|
||||
if len(errMsg) > 500 {
|
||||
errMsg = errMsg[:500] + "..."
|
||||
}
|
||||
content = fmt.Sprintf("[FAILED] %s", errMsg)
|
||||
} else if outcome.Result == nil {
|
||||
slog.Warn("teammate message: nil result without error", "from", senderID)
|
||||
return "", nil, false
|
||||
} else if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
|
||||
slog.Info("teammate message: suppressed silent/empty reply", "from", senderID)
|
||||
return "", nil, false
|
||||
} else {
|
||||
content = outcome.Result.Content
|
||||
media = outcome.Result.Media
|
||||
}
|
||||
|
||||
// Append member comments & attachments so leader sees them in the announce.
|
||||
if taskIDStr := inMeta[tools.MetaTeamTaskID]; taskIDStr != "" && deps.TeamStore != nil {
|
||||
if taskUUID, err := uuid.Parse(taskIDStr); err == nil {
|
||||
if comments, err := deps.TeamStore.ListRecentTaskComments(ctx, taskUUID, 5); err == nil && len(comments) > 0 {
|
||||
var parts []string
|
||||
for _, c := range comments {
|
||||
author := c.AgentKey
|
||||
if author == "" {
|
||||
author = "system"
|
||||
}
|
||||
text := c.Content
|
||||
if len([]rune(text)) > 500 {
|
||||
text = string([]rune(text)[:500]) + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("- [%s]: %s", author, text))
|
||||
}
|
||||
content += "\n\n[Member notes]\n" + strings.Join(parts, "\n")
|
||||
}
|
||||
if attachments, err := deps.TeamStore.ListTaskAttachments(ctx, taskUUID); err == nil && len(attachments) > 0 {
|
||||
content += "\n\n[Attached files in team workspace]"
|
||||
for _, a := range attachments {
|
||||
content += "\n- " + filepath.Base(a.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content, media, true
|
||||
}
|
||||
|
||||
// resolveTeammateLeadAgent resolves the lead agent key for routing a teammate announce.
|
||||
func resolveTeammateLeadAgent(ctx context.Context, cachedTeam *store.TeamData, inMeta map[string]string, deps *ConsumerDeps) string {
|
||||
if cachedTeam != nil {
|
||||
if leadAg, err := deps.AgentStore.GetByID(ctx, cachedTeam.LeadAgentID); err == nil {
|
||||
return leadAg.AgentKey
|
||||
}
|
||||
} else if teamIDStr := inMeta[tools.MetaTeamID]; teamIDStr != "" {
|
||||
if teamUUID, err := uuid.Parse(teamIDStr); err == nil {
|
||||
if team, err := deps.TeamStore.GetTeam(ctx, teamUUID); err == nil {
|
||||
if leadAg, err := deps.AgentStore.GetByID(ctx, team.LeadAgentID); err == nil {
|
||||
return leadAg.AgentKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if lead := inMeta[tools.MetaFromAgent]; lead != "" {
|
||||
return lead
|
||||
}
|
||||
return deps.Cfg.ResolveDefaultAgentID()
|
||||
}
|
||||
@@ -75,14 +75,14 @@ func extractSessionMetadata(msg bus.InboundMessage, peerKind string) map[string]
|
||||
meta["display_name"] = v
|
||||
}
|
||||
|
||||
if v := msg.Metadata["username"]; v != "" {
|
||||
meta["username"] = v
|
||||
if v := msg.Metadata[tools.MetaUsername]; v != "" {
|
||||
meta[tools.MetaUsername] = v
|
||||
}
|
||||
if peerKind != "" {
|
||||
meta["peer_kind"] = peerKind
|
||||
}
|
||||
if v := msg.Metadata["chat_title"]; v != "" {
|
||||
meta["chat_title"] = v
|
||||
if v := msg.Metadata[tools.MetaChatTitle]; v != "" {
|
||||
meta[tools.MetaChatTitle] = v
|
||||
}
|
||||
|
||||
if len(meta) == 0 {
|
||||
@@ -177,3 +177,19 @@ func resolveChannelType(channelMgr *channels.Manager, name string) string {
|
||||
}
|
||||
return channelMgr.ChannelTypeForName(name)
|
||||
}
|
||||
|
||||
// resolveSenderName extracts the sender display name from channel metadata.
|
||||
// Checks "sender_name" (Feishu), "first_name" (Telegram), "push_name" (WhatsApp).
|
||||
// Sanitizes to prevent prompt injection via newlines/control chars.
|
||||
func resolveSenderName(msg bus.InboundMessage) string {
|
||||
for _, key := range []string{"sender_name", "first_name", "push_name", "display_name"} {
|
||||
if name := msg.Metadata[key]; name != "" {
|
||||
clean := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.TrimSpace(name))
|
||||
if len([]rune(clean)) > 100 {
|
||||
clean = string([]rune(clean)[:100])
|
||||
}
|
||||
return clean
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -72,9 +72,9 @@ func processNormalMessage(
|
||||
}
|
||||
|
||||
// DM thread: override session key to isolate per-thread history in private chats.
|
||||
if msg.Metadata["dm_thread_id"] != "" && peerKind == string(sessions.PeerDirect) {
|
||||
if msg.Metadata[tools.MetaDMThreadID] != "" && peerKind == string(sessions.PeerDirect) {
|
||||
var threadID int
|
||||
fmt.Sscanf(msg.Metadata["dm_thread_id"], "%d", &threadID)
|
||||
fmt.Sscanf(msg.Metadata[tools.MetaDMThreadID], "%d", &threadID)
|
||||
if threadID > 0 {
|
||||
sessionKey = sessions.BuildDMThreadSessionKey(agentID, msg.Channel, msg.ChatID, threadID)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func processNormalMessage(
|
||||
// Also collect group chat as a contact (for group permission management / merge).
|
||||
// Group IDs (e.g., Telegram "-100456") differ from user IDs — no UNIQUE conflict.
|
||||
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
|
||||
groupTitle := msg.Metadata["chat_title"] // Telegram: message.Chat.Title
|
||||
groupTitle := msg.Metadata[tools.MetaChatTitle] // Telegram: message.Chat.Title
|
||||
deps.ContactCollector.EnsureContact(ctx, channelType, msg.Channel, msg.ChatID, "", groupTitle, "", "group", "group", "", "")
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func processNormalMessage(
|
||||
}
|
||||
|
||||
// Append per-topic system prompt (from group/topic config hierarchy).
|
||||
if tsp := msg.Metadata["topic_system_prompt"]; tsp != "" {
|
||||
if tsp := msg.Metadata[tools.MetaTopicSystemPrompt]; tsp != "" {
|
||||
if extraPrompt != "" {
|
||||
extraPrompt += "\n\n"
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func processNormalMessage(
|
||||
|
||||
// Per-topic skill filter override (from group/topic config hierarchy).
|
||||
var skillFilter []string
|
||||
if ts := msg.Metadata["topic_skills"]; ts != "" {
|
||||
if ts := msg.Metadata[tools.MetaTopicSkills]; ts != "" {
|
||||
skillFilter = strings.Split(ts, ",")
|
||||
}
|
||||
|
||||
@@ -352,12 +352,13 @@ func processNormalMessage(
|
||||
ForwardMedia: fwdMedia,
|
||||
Channel: msg.Channel,
|
||||
ChannelType: resolveChannelType(deps.ChannelMgr, msg.Channel),
|
||||
ChatTitle: msg.Metadata["chat_title"],
|
||||
ChatTitle: msg.Metadata[tools.MetaChatTitle],
|
||||
ChatID: msg.ChatID,
|
||||
PeerKind: peerKind,
|
||||
LocalKey: msg.Metadata["local_key"],
|
||||
UserID: userID,
|
||||
SenderID: msg.SenderID,
|
||||
SenderName: resolveSenderName(msg),
|
||||
RunID: runID,
|
||||
Stream: enableStream,
|
||||
HistoryLimit: msg.HistoryLimit,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// gatewayDeps holds shared dependencies used across the extracted gateway setup functions.
|
||||
// It is populated in runGateway() and passed to helper methods to avoid long parameter lists.
|
||||
type gatewayDeps struct {
|
||||
cfg *config.Config
|
||||
server *gateway.Server
|
||||
msgBus *bus.MessageBus
|
||||
pgStores *store.Stores
|
||||
providerRegistry *providers.Registry
|
||||
channelMgr *channels.Manager
|
||||
agentRouter *agent.Router
|
||||
toolsReg *tools.Registry
|
||||
skillsLoader *skills.Loader // optional: enables skill creation in evolution approval
|
||||
workspace string
|
||||
dataDir string
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// wireEventSubscribers registers team task audit and team progress notification subscribers on the message bus.
|
||||
// Must be called after pgStores and msgBus are initialized.
|
||||
func (d *gatewayDeps) wireEventSubscribers() {
|
||||
d.wireTeamTaskAuditSubscriber()
|
||||
d.wireTeamProgressNotifySubscriber()
|
||||
}
|
||||
|
||||
// wireTeamTaskAuditSubscriber persists team task lifecycle events to the team_task_events table.
|
||||
func (d *gatewayDeps) wireTeamTaskAuditSubscriber() {
|
||||
if d.pgStores.Teams == nil {
|
||||
return
|
||||
}
|
||||
teamEventStore := d.pgStores.Teams
|
||||
d.msgBus.Subscribe(bus.TopicTeamTaskAudit, func(evt bus.Event) {
|
||||
eventType := teamTaskEventType(evt.Name)
|
||||
if eventType == "" {
|
||||
return
|
||||
}
|
||||
payload, ok := evt.Payload.(protocol.TeamTaskEventPayload)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(payload.TaskID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Propagate tenant from bus event to ensure correct tenant isolation.
|
||||
auditCtx := store.WithTenantID(context.Background(), evt.TenantID)
|
||||
|
||||
// Populate data field with event-specific context for audit trail.
|
||||
var data json.RawMessage
|
||||
switch evt.Name {
|
||||
case protocol.EventTeamTaskFailed, protocol.EventTeamTaskRejected, protocol.EventTeamTaskCancelled:
|
||||
if payload.Reason != "" {
|
||||
data, _ = json.Marshal(map[string]string{"reason": payload.Reason})
|
||||
}
|
||||
case protocol.EventTeamTaskCommented:
|
||||
if payload.CommentText != "" {
|
||||
data, _ = json.Marshal(map[string]string{"comment_text": payload.CommentText})
|
||||
}
|
||||
case protocol.EventTeamTaskProgress:
|
||||
data, _ = json.Marshal(map[string]any{"progress_percent": payload.ProgressPercent, "progress_step": payload.ProgressStep})
|
||||
}
|
||||
|
||||
if err := teamEventStore.RecordTaskEvent(auditCtx, &store.TeamTaskEventData{
|
||||
TaskID: taskID,
|
||||
EventType: eventType,
|
||||
ActorType: payload.ActorType,
|
||||
ActorID: payload.ActorID,
|
||||
Data: data,
|
||||
}); err != nil {
|
||||
slog.Warn("team_task_audit.record_failed", "task_id", payload.TaskID, "event", eventType, "error", err)
|
||||
}
|
||||
})
|
||||
slog.Info("team task event subscriber registered")
|
||||
}
|
||||
|
||||
// wireTeamProgressNotifySubscriber forwards task events to chat channels.
|
||||
// Reads team.settings.notifications config; direct mode sends outbound, leader mode
|
||||
// injects into leader agent session. Notifications are batched per chat
|
||||
// with 2s debounce to avoid spamming users when multiple tasks dispatch at once.
|
||||
func (d *gatewayDeps) wireTeamProgressNotifySubscriber() {
|
||||
if d.pgStores.Teams == nil {
|
||||
return
|
||||
}
|
||||
notifyTeamStore := d.pgStores.Teams
|
||||
notifyAgentStore := d.pgStores.Agents
|
||||
teamNotifyQueue := tools.NewTeamNotifyQueue(2000, func(items []string, meta tools.NotifyRoutingMeta) {
|
||||
content := tools.FormatBatchedNotify(items)
|
||||
if meta.Mode == "leader" {
|
||||
leaderContent := fmt.Sprintf("[Auto-status — relay to user, NO task actions]\n%s\n\nBriefly inform the user. Do NOT create, retry, reassign, or modify any tasks.", content)
|
||||
d.msgBus.TryPublishInbound(bus.InboundMessage{
|
||||
Channel: meta.Channel,
|
||||
SenderID: "notification:progress",
|
||||
ChatID: meta.ChatID,
|
||||
AgentID: meta.LeadAgent,
|
||||
UserID: meta.UserID,
|
||||
PeerKind: meta.PeerKind,
|
||||
Content: leaderContent,
|
||||
Metadata: map[string]string{"run_kind": tools.RunKindNotification},
|
||||
})
|
||||
} else {
|
||||
d.msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: meta.Channel,
|
||||
ChatID: meta.ChatID,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
})
|
||||
d.msgBus.Subscribe("consumer.team-notify", func(evt bus.Event) {
|
||||
payload, ok := evt.Payload.(protocol.TeamTaskEventPayload)
|
||||
if !ok || payload.TeamID == "" || payload.Channel == "" {
|
||||
return
|
||||
}
|
||||
var notifyType string
|
||||
switch evt.Name {
|
||||
case protocol.EventTeamTaskDispatched:
|
||||
notifyType = "dispatched"
|
||||
case protocol.EventTeamTaskAssigned:
|
||||
notifyType = "dispatched" // same config flag — human assign also notifies
|
||||
case protocol.EventTeamTaskFailed:
|
||||
notifyType = "failed"
|
||||
case protocol.EventTeamTaskProgress:
|
||||
notifyType = "progress"
|
||||
case protocol.EventTeamTaskCompleted:
|
||||
notifyType = "completed"
|
||||
case protocol.EventTeamTaskCommented:
|
||||
notifyType = "commented"
|
||||
case protocol.EventTeamTaskCreated:
|
||||
// Only notify for human-created tasks (agent-created go through dispatch).
|
||||
if payload.ActorType != "human" {
|
||||
return
|
||||
}
|
||||
notifyType = "new_task"
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
teamUUID, err := uuid.Parse(payload.TeamID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
team, err := notifyTeamStore.GetTeamUnscoped(context.Background(), teamUUID)
|
||||
if err != nil || team == nil {
|
||||
return
|
||||
}
|
||||
teamNotifyCfg := tools.ParseTeamNotifyConfig(team.Settings)
|
||||
|
||||
// Check if this notification type is enabled.
|
||||
switch notifyType {
|
||||
case "dispatched":
|
||||
if !teamNotifyCfg.Dispatched {
|
||||
return
|
||||
}
|
||||
case "failed":
|
||||
if !teamNotifyCfg.Failed {
|
||||
return
|
||||
}
|
||||
case "progress":
|
||||
if !teamNotifyCfg.Progress {
|
||||
return
|
||||
}
|
||||
case "completed":
|
||||
if !teamNotifyCfg.Completed {
|
||||
return
|
||||
}
|
||||
case "commented":
|
||||
if !teamNotifyCfg.Commented {
|
||||
return
|
||||
}
|
||||
case "new_task":
|
||||
if !teamNotifyCfg.NewTask {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Skip internal channels.
|
||||
if payload.Channel == tools.ChannelSystem || payload.Channel == tools.ChannelTeammate {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve lead agent key (needed for leader mode routing + completed-by-leader skip).
|
||||
var leadAgentKey string
|
||||
if notifyAgentStore != nil {
|
||||
if la, err := notifyAgentStore.GetByIDUnscoped(context.Background(), team.LeadAgentID); err == nil {
|
||||
leadAgentKey = la.AgentKey
|
||||
}
|
||||
}
|
||||
|
||||
// Skip completed notification if task was completed by the leader
|
||||
// (leader is already talking to the user, notification would be redundant).
|
||||
if notifyType == "completed" && payload.OwnerAgentKey == leadAgentKey {
|
||||
return
|
||||
}
|
||||
|
||||
// Build notification message.
|
||||
var content string
|
||||
agentName := payload.OwnerAgentKey
|
||||
if payload.OwnerDisplayName != "" {
|
||||
agentName = payload.OwnerDisplayName
|
||||
}
|
||||
switch evt.Name {
|
||||
case protocol.EventTeamTaskDispatched:
|
||||
if payload.ActorID == "dispatch_unblocked" {
|
||||
content = fmt.Sprintf("▶️ Task #%d \"%s\" → unblocked, dispatched to %s", payload.TaskNumber, payload.Subject, agentName)
|
||||
} else {
|
||||
content = fmt.Sprintf("📋 Task #%d \"%s\" → dispatched to %s", payload.TaskNumber, payload.Subject, agentName)
|
||||
}
|
||||
case protocol.EventTeamTaskAssigned:
|
||||
content = fmt.Sprintf("📋 Task #%d \"%s\" → assigned to %s", payload.TaskNumber, payload.Subject, agentName)
|
||||
case protocol.EventTeamTaskCompleted:
|
||||
content = fmt.Sprintf("✅ Task #%d \"%s\" completed", payload.TaskNumber, payload.Subject)
|
||||
case protocol.EventTeamTaskProgress:
|
||||
if payload.ProgressStep != "" {
|
||||
content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%% — %s", payload.TaskNumber, payload.Subject, payload.ProgressPercent, payload.ProgressStep)
|
||||
} else {
|
||||
content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%%", payload.TaskNumber, payload.Subject, payload.ProgressPercent)
|
||||
}
|
||||
case protocol.EventTeamTaskFailed:
|
||||
reason := payload.Reason
|
||||
if len(reason) > 200 {
|
||||
reason = reason[:200] + "..."
|
||||
}
|
||||
content = fmt.Sprintf("❌ Task #%d \"%s\" failed: %s", payload.TaskNumber, payload.Subject, reason)
|
||||
case protocol.EventTeamTaskCommented:
|
||||
actor := payload.ActorID
|
||||
if actor == "" {
|
||||
actor = "unknown"
|
||||
}
|
||||
content = fmt.Sprintf("💬 Task #%d \"%s\": comment from %s", payload.TaskNumber, payload.Subject, actor)
|
||||
case protocol.EventTeamTaskCreated:
|
||||
content = fmt.Sprintf("📋 New task #%d \"%s\" created", payload.TaskNumber, payload.Subject)
|
||||
}
|
||||
|
||||
// In leader mode, require resolved agent key for routing.
|
||||
if teamNotifyCfg.Mode == "leader" && leadAgentKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
batchKey := payload.TeamID + ":" + payload.ChatID
|
||||
teamNotifyQueue.Enqueue(batchKey, content, tools.NotifyRoutingMeta{
|
||||
Mode: teamNotifyCfg.Mode,
|
||||
Channel: payload.Channel,
|
||||
ChatID: payload.ChatID,
|
||||
UserID: payload.UserID,
|
||||
LeadAgent: leadAgentKey,
|
||||
PeerKind: payload.PeerKind,
|
||||
})
|
||||
})
|
||||
slog.Info("team progress notification subscriber registered")
|
||||
}
|
||||
|
||||
// wireAuditSubscriber sets up the audit log subscriber that persists events to activity_logs.
|
||||
// Uses a buffered channel with a single worker to avoid unbounded goroutines.
|
||||
// Returns the audit channel so the shutdown goroutine can close it to flush pending entries.
|
||||
func (d *gatewayDeps) wireAuditSubscriber() chan bus.AuditEventPayload {
|
||||
if d.pgStores.Activity == nil {
|
||||
return nil
|
||||
}
|
||||
auditCh := make(chan bus.AuditEventPayload, 256)
|
||||
d.msgBus.Subscribe(bus.TopicAudit, func(evt bus.Event) {
|
||||
if evt.Name != protocol.EventAuditLog {
|
||||
return
|
||||
}
|
||||
payload, ok := evt.Payload.(bus.AuditEventPayload)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case auditCh <- payload:
|
||||
default:
|
||||
slog.Warn("audit.queue_full", "action", payload.Action)
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
for payload := range auditCh {
|
||||
auditCtx := store.WithTenantID(context.Background(), payload.TenantID)
|
||||
if err := d.pgStores.Activity.Log(auditCtx, &store.ActivityLog{
|
||||
ActorType: payload.ActorType,
|
||||
ActorID: payload.ActorID,
|
||||
Action: payload.Action,
|
||||
EntityType: payload.EntityType,
|
||||
EntityID: payload.EntityID,
|
||||
IPAddress: payload.IPAddress,
|
||||
Details: payload.Details,
|
||||
}); err != nil {
|
||||
slog.Warn("audit.log_failed", "action", payload.Action, "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
slog.Info("audit subscriber registered")
|
||||
return auditCh
|
||||
}
|
||||
|
||||
// wireChannelStreamingSubscriber subscribes to agent events for channel streaming/reaction forwarding.
|
||||
// Events emitted by agent loops are broadcast to the bus; we forward them to the channel manager
|
||||
// which routes to StreamingChannel/ReactionChannel. Also updates the Router activity registry.
|
||||
func (d *gatewayDeps) wireChannelStreamingSubscriber() {
|
||||
d.msgBus.Subscribe(bus.TopicChannelStreaming, func(event bus.Event) {
|
||||
if event.Name != protocol.EventAgent {
|
||||
return
|
||||
}
|
||||
agentEvent, ok := event.Payload.(agent.AgentEvent)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d.channelMgr.HandleAgentEvent(agentEvent.Type, agentEvent.RunID, agentEvent.Payload)
|
||||
|
||||
// Route activity events to Router (status registry) and DelegateManager (progress tracking).
|
||||
if agentEvent.Type == protocol.AgentEventActivity {
|
||||
payloadMap, _ := agentEvent.Payload.(map[string]any)
|
||||
phase, _ := payloadMap["phase"].(string)
|
||||
tool, _ := payloadMap["tool"].(string)
|
||||
iteration := 0
|
||||
if v, ok := payloadMap["iteration"].(int); ok {
|
||||
iteration = v
|
||||
}
|
||||
if sessionKey := d.agentRouter.SessionKeyForRun(agentEvent.RunID); sessionKey != "" {
|
||||
d.agentRouter.UpdateActivity(sessionKey, agentEvent.RunID, phase, tool, iteration)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear activity on terminal events
|
||||
if agentEvent.Type == protocol.AgentEventRunCompleted ||
|
||||
agentEvent.Type == protocol.AgentEventRunFailed ||
|
||||
agentEvent.Type == protocol.AgentEventRunCancelled {
|
||||
if sessionKey := d.agentRouter.SessionKeyForRun(agentEvent.RunID); sessionKey != "" {
|
||||
d.agentRouter.ClearActivity(sessionKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// teamTaskEventType maps bus event names to team_task_events.event_type values.
|
||||
// Returns empty string for non-task events (caller should skip).
|
||||
func teamTaskEventType(eventName string) string {
|
||||
switch eventName {
|
||||
case protocol.EventTeamTaskCreated:
|
||||
return "created"
|
||||
case protocol.EventTeamTaskClaimed:
|
||||
return "claimed"
|
||||
case protocol.EventTeamTaskAssigned:
|
||||
return "assigned"
|
||||
case protocol.EventTeamTaskDispatched:
|
||||
return "dispatched"
|
||||
case protocol.EventTeamTaskCompleted:
|
||||
return "completed"
|
||||
case protocol.EventTeamTaskFailed:
|
||||
return "failed"
|
||||
case protocol.EventTeamTaskCancelled:
|
||||
return "cancelled"
|
||||
case protocol.EventTeamTaskReviewed:
|
||||
return "reviewed"
|
||||
case protocol.EventTeamTaskApproved:
|
||||
return "approved"
|
||||
case protocol.EventTeamTaskRejected:
|
||||
return "rejected"
|
||||
case protocol.EventTeamTaskCommented:
|
||||
return "commented"
|
||||
case protocol.EventTeamTaskProgress:
|
||||
return "progress"
|
||||
case protocol.EventTeamTaskUpdated:
|
||||
return "updated"
|
||||
case protocol.EventTeamTaskStale:
|
||||
return "stale"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// runEvolutionCron runs the v3 evolution suggestion engine (daily) and
|
||||
// evaluation/rollback check (weekly) as background goroutines.
|
||||
// Designed to be called with `go runEvolutionCron(...)`.
|
||||
func runEvolutionCron(stores *store.Stores, engine *agent.SuggestionEngine) {
|
||||
dailyTicker := time.NewTicker(24 * time.Hour)
|
||||
defer dailyTicker.Stop()
|
||||
|
||||
weeklyTicker := time.NewTicker(7 * 24 * time.Hour)
|
||||
defer weeklyTicker.Stop()
|
||||
|
||||
// Run first analysis 1 minute after startup (warm-up).
|
||||
time.Sleep(1 * time.Minute)
|
||||
runSuggestionAnalysis(stores, engine)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-dailyTicker.C:
|
||||
runSuggestionAnalysis(stores, engine)
|
||||
case <-weeklyTicker.C:
|
||||
runEvolutionEvaluation(stores)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runSuggestionAnalysis lists agents with evolution metrics enabled and runs analysis.
|
||||
func runSuggestionAnalysis(stores *store.Stores, engine *agent.SuggestionEngine) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// List all agents (empty ownerID = all, tenant-scoped via context).
|
||||
agents, err := stores.Agents.List(ctx, "")
|
||||
if err != nil {
|
||||
slog.Warn("evolution.cron.list_agents_failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
for _, ag := range agents {
|
||||
if ag.Status != store.AgentStatusActive {
|
||||
continue
|
||||
}
|
||||
flags := ag.ParseV3Flags()
|
||||
if !flags.EvolutionMetrics {
|
||||
continue
|
||||
}
|
||||
agentCtx := store.WithTenantID(ctx, ag.TenantID)
|
||||
if _, err := engine.Analyze(agentCtx, ag.ID); err != nil {
|
||||
slog.Debug("evolution.cron.analyze_failed", "agent", ag.ID, "error", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
slog.Info("evolution.cron.analysis_complete", "agents", count)
|
||||
}
|
||||
}
|
||||
|
||||
// runEvolutionEvaluation checks applied suggestions and rolls back quality drops.
|
||||
func runEvolutionEvaluation(stores *store.Stores) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
agents, err := stores.Agents.List(ctx, "")
|
||||
if err != nil {
|
||||
slog.Warn("evolution.cron.eval_list_failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
guardrails := agent.DefaultGuardrails()
|
||||
for _, ag := range agents {
|
||||
if ag.Status != store.AgentStatusActive {
|
||||
continue
|
||||
}
|
||||
flags := ag.ParseV3Flags()
|
||||
if !flags.EvolutionMetrics {
|
||||
continue
|
||||
}
|
||||
agentCtx := store.WithTenantID(ctx, ag.TenantID)
|
||||
if err := agent.EvaluateApplied(agentCtx, ag.ID, guardrails, stores.EvolutionMetrics, stores.EvolutionSuggestions, stores.Agents); err != nil {
|
||||
slog.Debug("evolution.cron.eval_failed", "agent", ag.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,22 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/heartbeat"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// makeHeartbeatRunFn creates a function that routes a heartbeat run through the scheduler's cron lane.
|
||||
@@ -13,3 +26,69 @@ func makeHeartbeatRunFn(sched *scheduler.Scheduler) func(ctx context.Context, re
|
||||
return sched.Schedule(ctx, scheduler.LaneCron, req)
|
||||
}
|
||||
}
|
||||
|
||||
// startCronAndHeartbeat starts the cron service and heartbeat ticker, wires the heartbeat
|
||||
// wake function to the tool + RPC methods, and sets the adaptive token estimate function.
|
||||
// Returns the heartbeat ticker (needed by lifecycle for shutdown).
|
||||
func startCronAndHeartbeat(
|
||||
pgStores *store.Stores,
|
||||
server *gateway.Server,
|
||||
sched *scheduler.Scheduler,
|
||||
msgBus *bus.MessageBus,
|
||||
providerRegistry *providers.Registry,
|
||||
channelMgr *channels.Manager,
|
||||
cfg *config.Config,
|
||||
heartbeatTool *tools.HeartbeatTool,
|
||||
heartbeatMethods *methods.HeartbeatMethods,
|
||||
) *heartbeat.Ticker {
|
||||
// Start cron service with job handler (routes through scheduler's cron lane)
|
||||
pgStores.Cron.SetOnJob(makeCronJobHandler(sched, msgBus, cfg, channelMgr, pgStores.Sessions, pgStores.Agents))
|
||||
pgStores.Cron.SetOnEvent(func(event store.CronEvent) {
|
||||
server.BroadcastEvent(*protocol.NewEvent(protocol.EventCron, event))
|
||||
})
|
||||
if err := pgStores.Cron.Start(); err != nil {
|
||||
slog.Warn("cron service failed to start", "error", err)
|
||||
}
|
||||
|
||||
// Start heartbeat ticker (routes through scheduler's cron lane)
|
||||
heartbeatTicker := heartbeat.NewTicker(heartbeat.TickerConfig{
|
||||
Store: pgStores.Heartbeats,
|
||||
Agents: pgStores.Agents,
|
||||
Sessions: pgStores.Sessions,
|
||||
ProviderStore: pgStores.Providers,
|
||||
ProviderReg: providerRegistry,
|
||||
MsgBus: msgBus,
|
||||
Sched: sched,
|
||||
RunAgent: makeHeartbeatRunFn(sched),
|
||||
})
|
||||
heartbeatTicker.SetOnEvent(func(event store.HeartbeatEvent) {
|
||||
server.BroadcastEvent(*protocol.NewEvent(protocol.EventHeartbeat, event))
|
||||
})
|
||||
heartbeatTicker.Start()
|
||||
|
||||
// Wire heartbeat wake function to tool + RPC + cron wakeMode
|
||||
heartbeatTool.SetWakeFn(heartbeatTicker.Wake)
|
||||
heartbeatMethods.SetWakeFn(heartbeatTicker.Wake)
|
||||
heartbeatMethods.SetAgentStore(pgStores.Agents)
|
||||
heartbeatMethods.SetProviderStore(pgStores.Providers)
|
||||
cronHeartbeatWakeFn = func(agentID string) {
|
||||
if id, err := uuid.Parse(agentID); err == nil {
|
||||
heartbeatTicker.Wake(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive throttle: reduce per-session concurrency when nearing the summary threshold.
|
||||
sched.SetTokenEstimateFunc(func(sessionKey string) (int, int) {
|
||||
bctx := context.Background()
|
||||
history := pgStores.Sessions.GetHistory(bctx, sessionKey)
|
||||
lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(bctx, sessionKey)
|
||||
tokens := agent.EstimateTokensWithCalibration(history, lastPT, lastMC)
|
||||
cw := pgStores.Sessions.GetContextWindow(bctx, sessionKey)
|
||||
if cw <= 0 {
|
||||
cw = config.DefaultContextWindow
|
||||
}
|
||||
return tokens, cw
|
||||
})
|
||||
|
||||
return heartbeatTicker
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// gatewayHTTPError represents a structured error from the gateway HTTP API.
|
||||
type gatewayHTTPError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *gatewayHTTPError) Error() string {
|
||||
return fmt.Sprintf("gateway error (%d): %s", e.StatusCode, e.Message)
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// healthClient has a shorter timeout for quick health checks.
|
||||
var healthClient = &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
// resolveGatewayBaseURL reads host/port from config and returns http://host:port.
|
||||
func resolveGatewayBaseURL() string {
|
||||
cfg, err := config.Load(resolveConfigPath())
|
||||
if err != nil {
|
||||
return "http://127.0.0.1:18790"
|
||||
}
|
||||
host := cfg.Gateway.Host
|
||||
if host == "" || host == "0.0.0.0" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := cfg.Gateway.Port
|
||||
if port == 0 {
|
||||
port = 18790
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%d", host, port)
|
||||
}
|
||||
|
||||
// resolveGatewayToken returns the gateway auth token.
|
||||
// Priority: GOCLAW_GATEWAY_TOKEN env → config file token.
|
||||
func resolveGatewayToken() string {
|
||||
if t := os.Getenv("GOCLAW_GATEWAY_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
cfg, _ := config.Load(resolveConfigPath())
|
||||
if cfg != nil {
|
||||
return cfg.Gateway.Token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// gatewayHTTPDo sends an HTTP request to the gateway with auth and returns the parsed JSON response.
|
||||
func gatewayHTTPDo(method, path string, body any) (map[string]any, error) {
|
||||
raw, status, err := gatewayHTTPDoRaw(method, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// DELETE with 204 No Content
|
||||
if status == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if status >= 400 {
|
||||
return nil, parseHTTPError(raw, status)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON response from gateway: %s", string(raw))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Convenience wrappers
|
||||
|
||||
func gatewayHTTPGet(path string) (map[string]any, error) {
|
||||
return gatewayHTTPDo(http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
func gatewayHTTPPost(path string, body any) (map[string]any, error) {
|
||||
return gatewayHTTPDo(http.MethodPost, path, body)
|
||||
}
|
||||
|
||||
func gatewayHTTPPut(path string, body any) (map[string]any, error) {
|
||||
return gatewayHTTPDo(http.MethodPut, path, body)
|
||||
}
|
||||
|
||||
func gatewayHTTPDelete(path string) error {
|
||||
_, err := gatewayHTTPDo(http.MethodDelete, path, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// gatewayHTTPDoRaw executes an HTTP request and returns the raw response bytes.
|
||||
// Shared by both map-based and typed response functions.
|
||||
func gatewayHTTPDoRaw(method, path string, body any) ([]byte, int, error) {
|
||||
base := resolveGatewayBaseURL()
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, base+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token := resolveGatewayToken(); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("cannot reach gateway at %s: %w", base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return raw, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// parseHTTPError extracts an error message from a gateway error response.
|
||||
func parseHTTPError(raw []byte, statusCode int) error {
|
||||
var errBody map[string]any
|
||||
if json.Unmarshal(raw, &errBody) == nil {
|
||||
if errVal, ok := errBody["error"]; ok {
|
||||
switch v := errVal.(type) {
|
||||
case string:
|
||||
return &gatewayHTTPError{StatusCode: statusCode, Message: v}
|
||||
case map[string]any:
|
||||
if m, ok := v["message"].(string); ok {
|
||||
return &gatewayHTTPError{StatusCode: statusCode, Message: m}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &gatewayHTTPError{StatusCode: statusCode, Message: string(raw)}
|
||||
}
|
||||
|
||||
// gatewayHTTPGetTyped sends a GET request and unmarshals the response into the typed struct.
|
||||
func gatewayHTTPGetTyped[T any](path string) (T, error) {
|
||||
var zero T
|
||||
raw, status, err := gatewayHTTPDoRaw(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if status >= 400 {
|
||||
return zero, parseHTTPError(raw, status)
|
||||
}
|
||||
var result T
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return zero, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// gatewayHTTPPostTyped sends a POST request and unmarshals the response into the typed struct.
|
||||
func gatewayHTTPPostTyped[T any](path string, body any) (T, error) {
|
||||
var zero T
|
||||
raw, status, err := gatewayHTTPDoRaw(http.MethodPost, path, body)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if status >= 400 {
|
||||
return zero, parseHTTPError(raw, status)
|
||||
}
|
||||
var result T
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return zero, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// requireRunningGatewayHTTP checks /health endpoint, exits with message if gateway is down.
|
||||
func requireRunningGatewayHTTP() {
|
||||
base := resolveGatewayBaseURL()
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/health", nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error: cannot build health check request.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := healthClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error: the gateway is not running.")
|
||||
fmt.Fprintf(os.Stderr, "Start it first: goclaw\n")
|
||||
fmt.Fprintf(os.Stderr, " (tried %s/health)\n", base)
|
||||
os.Exit(1)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "Error: gateway health check returned %d.\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
||||
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/media"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// httpHandlers bundles the results of wireHTTP() for passing to wireHTTPHandlersOnServer.
|
||||
type httpHandlers struct {
|
||||
agents *httpapi.AgentsHandler
|
||||
skills *httpapi.SkillsHandler
|
||||
traces *httpapi.TracesHandler
|
||||
mcp *httpapi.MCPHandler
|
||||
channelInstances *httpapi.ChannelInstancesHandler
|
||||
providers *httpapi.ProvidersHandler
|
||||
builtinTools *httpapi.BuiltinToolsHandler
|
||||
pendingMessages *httpapi.PendingMessagesHandler
|
||||
teamEvents *httpapi.TeamEventsHandler
|
||||
secureCLI *httpapi.SecureCLIHandler
|
||||
secureCLIGrant *httpapi.SecureCLIGrantHandler
|
||||
mcpUserCreds *httpapi.MCPUserCredentialsHandler
|
||||
}
|
||||
|
||||
// wireHTTPHandlersOnServer registers all HTTP handler objects onto the gateway server.
|
||||
// Called after wireHTTP() and wireExtras() have returned their results.
|
||||
func (d *gatewayDeps) wireHTTPHandlersOnServer(
|
||||
h httpHandlers,
|
||||
wakeH *httpapi.WakeHandler,
|
||||
mcpPool *mcpbridge.Pool,
|
||||
postTurn tools.PostTurnProcessor,
|
||||
mediaStore *media.Store,
|
||||
) {
|
||||
if h.providers != nil {
|
||||
h.providers.SetAPIBaseFallback(d.cfg.Providers.APIBaseForType)
|
||||
}
|
||||
if h.agents != nil {
|
||||
d.server.SetAgentsHandler(h.agents)
|
||||
}
|
||||
if h.skills != nil {
|
||||
d.server.SetSkillsHandler(h.skills)
|
||||
}
|
||||
if h.traces != nil {
|
||||
d.server.SetTracesHandler(h.traces)
|
||||
}
|
||||
// External wake/trigger API — wakeH was created by caller before invoking this method.
|
||||
d.server.SetWakeHandler(wakeH)
|
||||
if h.mcp != nil {
|
||||
if mcpPool != nil {
|
||||
h.mcp.SetPoolEvictor(mcpPool)
|
||||
}
|
||||
d.server.SetMCPHandler(h.mcp)
|
||||
}
|
||||
if h.mcpUserCreds != nil {
|
||||
d.server.SetMCPUserCredentialsHandler(h.mcpUserCreds)
|
||||
}
|
||||
if h.channelInstances != nil {
|
||||
d.server.SetChannelInstancesHandler(h.channelInstances)
|
||||
}
|
||||
if h.providers != nil {
|
||||
d.server.SetProvidersHandler(h.providers)
|
||||
}
|
||||
if h.teamEvents != nil {
|
||||
d.server.SetTeamEventsHandler(h.teamEvents)
|
||||
}
|
||||
if d.pgStores != nil && d.pgStores.Teams != nil {
|
||||
d.server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(d.pgStores.Teams, d.workspace))
|
||||
d.server.SetWorkspaceUploadHandler(httpapi.NewWorkspaceUploadHandler(d.pgStores.Teams, d.workspace, d.msgBus))
|
||||
}
|
||||
if h.builtinTools != nil {
|
||||
d.server.SetBuiltinToolsHandler(h.builtinTools)
|
||||
}
|
||||
if h.pendingMessages != nil {
|
||||
if pc := d.cfg.Channels.PendingCompaction; pc != nil {
|
||||
h.pendingMessages.SetKeepRecent(pc.KeepRecent)
|
||||
h.pendingMessages.SetMaxTokens(pc.MaxTokens)
|
||||
h.pendingMessages.SetProviderModel(pc.Provider, pc.Model)
|
||||
}
|
||||
d.server.SetPendingMessagesHandler(h.pendingMessages)
|
||||
}
|
||||
if h.secureCLI != nil {
|
||||
d.server.SetSecureCLIHandler(h.secureCLI)
|
||||
}
|
||||
if h.secureCLIGrant != nil {
|
||||
d.server.SetSecureCLIGrantHandler(h.secureCLIGrant)
|
||||
}
|
||||
|
||||
// Activity audit log API
|
||||
if d.pgStores.Activity != nil {
|
||||
d.server.SetActivityHandler(httpapi.NewActivityHandler(d.pgStores.Activity))
|
||||
}
|
||||
|
||||
// System configs API
|
||||
if d.pgStores.SystemConfigs != nil {
|
||||
d.server.SetSystemConfigsHandler(httpapi.NewSystemConfigsHandler(d.pgStores.SystemConfigs, d.msgBus))
|
||||
|
||||
// Refresh in-memory config when system_configs change via HTTP API
|
||||
d.msgBus.Subscribe(bus.TopicSystemConfigChanged, func(evt bus.Event) {
|
||||
// Use tenant context from the request that triggered the change
|
||||
ctx := context.Background()
|
||||
if reqCtx, ok := evt.Payload.(context.Context); ok {
|
||||
ctx = reqCtx
|
||||
} else {
|
||||
ctx = store.WithTenantID(ctx, store.MasterTenantID)
|
||||
}
|
||||
if sysConfigs, err := d.pgStores.SystemConfigs.List(ctx); err == nil && len(sysConfigs) > 0 {
|
||||
d.cfg.ApplySystemConfigs(sysConfigs)
|
||||
// Update PGMemoryStore chunk config so new documents use updated settings
|
||||
if mem := d.cfg.Agents.Defaults.Memory; mem != nil {
|
||||
if pgMem, ok := d.pgStores.Memory.(*pg.PGMemoryStore); ok {
|
||||
pgMem.UpdateChunkConfig(mem.MaxChunkLen, mem.ChunkOverlap)
|
||||
}
|
||||
}
|
||||
slog.Debug("system_configs refreshed to in-memory config", "keys", len(sysConfigs))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Usage analytics API
|
||||
if d.pgStores.Snapshots != nil {
|
||||
d.server.SetUsageHandler(httpapi.NewUsageHandler(d.pgStores.Snapshots, d.pgStores.DB))
|
||||
}
|
||||
|
||||
// Runtime package management (install/uninstall system/pip/npm packages)
|
||||
d.server.SetPackagesHandler(httpapi.NewPackagesHandler())
|
||||
|
||||
// API documentation (OpenAPI spec + Swagger UI at /docs)
|
||||
d.server.SetDocsHandler(httpapi.NewDocsHandler())
|
||||
|
||||
// Edition info (public, no auth — used by desktop UI comparison modal)
|
||||
d.server.SetEditionHandler(httpapi.NewEditionHandler())
|
||||
|
||||
if d.pgStores != nil && d.pgStores.APIKeys != nil {
|
||||
d.server.SetAPIKeysHandler(httpapi.NewAPIKeysHandler(d.pgStores.APIKeys, d.msgBus))
|
||||
d.server.SetAPIKeyStore(d.pgStores.APIKeys)
|
||||
httpapi.InitAPIKeyCache(d.pgStores.APIKeys, d.msgBus)
|
||||
}
|
||||
|
||||
// Allow browser-paired users to access HTTP APIs
|
||||
if d.pgStores.Pairing != nil {
|
||||
httpapi.InitPairingAuth(d.pgStores.Pairing)
|
||||
}
|
||||
|
||||
// Memory management API
|
||||
if d.pgStores != nil && d.pgStores.Memory != nil {
|
||||
d.server.SetMemoryHandler(httpapi.NewMemoryHandler(d.pgStores.Memory))
|
||||
}
|
||||
|
||||
// Knowledge graph API
|
||||
if d.pgStores != nil && d.pgStores.KnowledgeGraph != nil {
|
||||
d.server.SetKnowledgeGraphHandler(httpapi.NewKnowledgeGraphHandler(d.pgStores.KnowledgeGraph, d.providerRegistry))
|
||||
}
|
||||
|
||||
// V3: Evolution metrics + suggestions API
|
||||
if d.pgStores != nil && d.pgStores.EvolutionMetrics != nil && d.pgStores.EvolutionSuggestions != nil {
|
||||
var evoOpts []httpapi.EvolutionHandlerOpt
|
||||
if manageStore, ok := d.pgStores.Skills.(store.SkillManageStore); ok && d.skillsLoader != nil {
|
||||
evoOpts = append(evoOpts, httpapi.WithSkillCreation(manageStore, d.skillsLoader, d.dataDir))
|
||||
}
|
||||
if d.pgStores.Agents != nil {
|
||||
evoOpts = append(evoOpts, httpapi.WithAgentStore(d.pgStores.Agents))
|
||||
}
|
||||
d.server.SetEvolutionHandler(httpapi.NewEvolutionHandler(d.pgStores.EvolutionMetrics, d.pgStores.EvolutionSuggestions, evoOpts...))
|
||||
}
|
||||
|
||||
// V3: Knowledge Vault document API
|
||||
if d.pgStores != nil && d.pgStores.Vault != nil {
|
||||
d.server.SetVaultHandler(httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams))
|
||||
}
|
||||
|
||||
// V3: Episodic memory summaries API
|
||||
if d.pgStores != nil && d.pgStores.Episodic != nil {
|
||||
d.server.SetEpisodicHandler(httpapi.NewEpisodicHandler(d.pgStores.Episodic))
|
||||
}
|
||||
|
||||
// V3: Orchestration mode API (read-only)
|
||||
if d.pgStores != nil && d.pgStores.Agents != nil {
|
||||
d.server.SetOrchestrationHandler(httpapi.NewOrchestrationHandler(d.pgStores.Agents, d.pgStores.Teams, d.pgStores.AgentLinks))
|
||||
}
|
||||
|
||||
// V3: Per-agent v3 feature flags API
|
||||
if d.pgStores != nil && d.pgStores.Agents != nil {
|
||||
d.server.SetV3FlagsHandler(httpapi.NewV3FlagsHandler(d.pgStores.Agents))
|
||||
}
|
||||
|
||||
// Workspace file serving endpoint — serves files by absolute path, auth-token protected.
|
||||
d.server.SetFilesHandler(httpapi.NewFilesHandler(d.workspace, d.dataDir))
|
||||
|
||||
// Storage file management — browse/delete files under the resolved workspace directory.
|
||||
d.server.SetStorageHandler(httpapi.NewStorageHandler(d.workspace))
|
||||
|
||||
// Media upload endpoint — accepts multipart file uploads, returns temp path + MIME type.
|
||||
d.server.SetMediaUploadHandler(httpapi.NewMediaUploadHandler())
|
||||
|
||||
// Media serve endpoint — serves persisted media files by ID for WS/web clients.
|
||||
if mediaStore != nil {
|
||||
d.server.SetMediaServeHandler(httpapi.NewMediaServeHandler(mediaStore))
|
||||
}
|
||||
|
||||
// Seed + apply builtin tool disables
|
||||
if d.pgStores.BuiltinTools != nil {
|
||||
seedBuiltinTools(context.Background(), d.pgStores.BuiltinTools)
|
||||
migrateBuiltinToolSettings(context.Background(), d.pgStores.BuiltinTools)
|
||||
backfillWebFetchSettings(context.Background(), d.pgStores.BuiltinTools)
|
||||
applyBuiltinToolDisables(context.Background(), d.pgStores.BuiltinTools, d.toolsReg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/cache"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/heartbeat"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tasks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// lifecycleDeps bundles the extra parameters needed by runLifecycle that are not in gatewayDeps.
|
||||
type lifecycleDeps struct {
|
||||
sched *scheduler.Scheduler
|
||||
heartbeatTicker *heartbeat.Ticker
|
||||
quotaChecker *channels.QuotaChecker
|
||||
webFetchTool *tools.WebFetchTool
|
||||
ttsTool *tools.TtsTool
|
||||
sandboxMgr sandbox.Manager
|
||||
postTurn tools.PostTurnProcessor
|
||||
subagentMgr *tools.SubagentManager
|
||||
consumerTeamStore store.TeamStore
|
||||
auditCh chan bus.AuditEventPayload
|
||||
sigCh chan os.Signal
|
||||
}
|
||||
|
||||
// runLifecycle wires config-reload subscribers, starts consumers, task recovery,
|
||||
// the signal handler goroutine, and finally starts the gateway server.
|
||||
// This is the last phase of runGateway() — called after all setup is complete.
|
||||
func (d *gatewayDeps) runLifecycle(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
deps lifecycleDeps,
|
||||
) {
|
||||
// Reload quota config on config changes via pub/sub.
|
||||
if deps.quotaChecker != nil {
|
||||
d.msgBus.Subscribe("quota-config-reload", func(evt bus.Event) {
|
||||
if evt.Name != bus.TopicConfigChanged {
|
||||
return
|
||||
}
|
||||
updatedCfg, ok := evt.Payload.(*config.Config)
|
||||
if !ok || updatedCfg.Gateway.Quota == nil {
|
||||
return
|
||||
}
|
||||
config.MergeChannelGroupQuotas(updatedCfg)
|
||||
deps.quotaChecker.UpdateConfig(*updatedCfg.Gateway.Quota)
|
||||
slog.Info("quota config reloaded via pub/sub")
|
||||
})
|
||||
}
|
||||
|
||||
// Reload cron default timezone on config changes via pub/sub.
|
||||
d.msgBus.Subscribe("cron-config-reload", func(evt bus.Event) {
|
||||
if evt.Name != bus.TopicConfigChanged {
|
||||
return
|
||||
}
|
||||
updatedCfg, ok := evt.Payload.(*config.Config)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d.pgStores.Cron.SetDefaultTimezone(updatedCfg.Cron.DefaultTimezone)
|
||||
})
|
||||
|
||||
// Reload web_fetch domain policy on config changes via pub/sub.
|
||||
d.msgBus.Subscribe("webfetch-config-reload", func(evt bus.Event) {
|
||||
if evt.Name != bus.TopicConfigChanged {
|
||||
return
|
||||
}
|
||||
updatedCfg, ok := evt.Payload.(*config.Config)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deps.webFetchTool.UpdatePolicy(updatedCfg.Tools.WebFetch.Policy, updatedCfg.Tools.WebFetch.AllowedDomains, updatedCfg.Tools.WebFetch.BlockedDomains)
|
||||
})
|
||||
|
||||
// Reload TTS providers on config changes via pub/sub.
|
||||
d.msgBus.Subscribe("tts-config-reload", func(evt bus.Event) {
|
||||
if evt.Name != bus.TopicConfigChanged {
|
||||
return
|
||||
}
|
||||
updatedCfg, ok := evt.Payload.(*config.Config)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if d.pgStores.ConfigSecrets != nil {
|
||||
if secrets, err := d.pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
|
||||
updatedCfg.ApplyDBSecrets(secrets)
|
||||
}
|
||||
}
|
||||
newMgr := setupTTS(updatedCfg)
|
||||
if newMgr == nil {
|
||||
return
|
||||
}
|
||||
deps.ttsTool.UpdateManager(newMgr)
|
||||
slog.Info("tts config reloaded", "provider", newMgr.PrimaryProvider(), "auto", string(newMgr.AutoMode()))
|
||||
})
|
||||
|
||||
// Log orphaned providers on agent deletion. Auto-delete is unsafe because
|
||||
// providers can be referenced by heartbeats (FK), OAuth tokens, media chains.
|
||||
d.msgBus.Subscribe("agent-deleted-provider-log", func(evt bus.Event) {
|
||||
if evt.Name != bus.TopicAgentDeleted {
|
||||
return
|
||||
}
|
||||
payload, ok := evt.Payload.(bus.AgentDeletedPayload)
|
||||
if !ok || payload.Provider == "" {
|
||||
return
|
||||
}
|
||||
slog.Info("agent deleted, provider may be orphaned — verify via UI",
|
||||
"agent", payload.AgentKey, "provider", payload.Provider)
|
||||
})
|
||||
|
||||
// Contact collector: auto-collect user info from channels with in-memory dedup cache.
|
||||
var contactCollector *store.ContactCollector
|
||||
if d.pgStores.Contacts != nil {
|
||||
contactCollector = store.NewContactCollector(d.pgStores.Contacts, cache.NewInMemoryCache[bool]())
|
||||
d.channelMgr.SetContactCollector(contactCollector)
|
||||
}
|
||||
|
||||
go consumeInboundMessages(ctx, d.msgBus, d.agentRouter, d.cfg, deps.sched, d.channelMgr, deps.consumerTeamStore, deps.quotaChecker, d.pgStores.Sessions, d.pgStores.Agents, contactCollector, deps.postTurn, deps.subagentMgr)
|
||||
|
||||
// Task recovery ticker: re-dispatches stale/pending team tasks on startup and periodically.
|
||||
var taskTicker *tasks.TaskTicker
|
||||
if d.pgStores.Teams != nil {
|
||||
taskTicker = tasks.NewTaskTicker(d.pgStores.Teams, d.pgStores.Agents, d.msgBus, d.cfg.Gateway.TaskRecoveryIntervalSec)
|
||||
taskTicker.Start()
|
||||
}
|
||||
|
||||
go func() {
|
||||
sig := <-deps.sigCh
|
||||
slog.Info("graceful shutdown initiated", "signal", sig)
|
||||
|
||||
// Broadcast shutdown event
|
||||
d.server.BroadcastEvent(*protocol.NewEvent(protocol.EventShutdown, nil))
|
||||
|
||||
// Stop channels, cron, heartbeat, and task ticker
|
||||
d.channelMgr.StopAll(context.Background())
|
||||
d.pgStores.Cron.Stop()
|
||||
deps.heartbeatTicker.Stop()
|
||||
if taskTicker != nil {
|
||||
taskTicker.Stop()
|
||||
}
|
||||
|
||||
// Drain audit log queue before closing DB
|
||||
if deps.auditCh != nil {
|
||||
close(deps.auditCh)
|
||||
}
|
||||
|
||||
// Close provider resources (e.g. Claude CLI temp files)
|
||||
d.providerRegistry.Close()
|
||||
|
||||
// Stop sandbox pruning + release containers
|
||||
if deps.sandboxMgr != nil {
|
||||
deps.sandboxMgr.Stop()
|
||||
slog.Info("releasing sandbox containers...")
|
||||
deps.sandboxMgr.ReleaseAll(context.Background())
|
||||
}
|
||||
|
||||
if deps.sched != nil {
|
||||
slog.Info("gateway: draining active runs", "timeout", "5s")
|
||||
deps.sched.Stop() // MarkDraining + StopAll
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}()
|
||||
|
||||
slog.Info("goclaw gateway starting",
|
||||
"version", Version,
|
||||
"protocol", protocol.ProtocolVersion,
|
||||
"agents", d.agentRouter.List(),
|
||||
"tools", d.toolsReg.Count(),
|
||||
"channels", d.channelMgr.GetEnabledChannels(),
|
||||
)
|
||||
|
||||
// Tailscale listener: build the mux first, then pass it to initTailscale
|
||||
// so the same routes are served on both the main listener and Tailscale.
|
||||
// Compiled via build tags: `go build -tags tsnet` to enable.
|
||||
mux := d.server.BuildMux()
|
||||
|
||||
// Mount channel webhook handlers on the main mux (e.g. Feishu /feishu/events).
|
||||
// This allows webhook-based channels to share the main server port.
|
||||
for _, route := range d.channelMgr.WebhookHandlers() {
|
||||
mux.Handle(route.Path, route.Handler)
|
||||
slog.Info("webhook route mounted on gateway", "path", route.Path)
|
||||
}
|
||||
|
||||
tsCleanup := initTailscale(ctx, d.cfg, mux)
|
||||
if tsCleanup != nil {
|
||||
defer tsCleanup()
|
||||
}
|
||||
|
||||
// Phase 1: suggest localhost binding when Tailscale is active
|
||||
if d.cfg.Tailscale.Hostname != "" && d.cfg.Gateway.Host == "0.0.0.0" {
|
||||
slog.Info("Tailscale enabled. Consider setting GOCLAW_HOST=127.0.0.1 for localhost-only + Tailscale access")
|
||||
}
|
||||
|
||||
// Security warnings
|
||||
if strings.Contains(d.cfg.Database.PostgresDSN, ":goclaw@") {
|
||||
slog.Warn("security.default_db_password: using default Postgres password — run ./prepare-env.sh to generate a strong one")
|
||||
}
|
||||
if len(d.cfg.Gateway.AllowedOrigins) > 0 {
|
||||
slog.Info("cors: allowed_origins configured", "origins", d.cfg.Gateway.AllowedOrigins)
|
||||
} else if !edition.Current().IsLimited() {
|
||||
slog.Warn("security.cors_open: no allowed_origins configured — all WebSocket origins accepted. Set gateway.allowed_origins or GOCLAW_ALLOWED_ORIGINS for production")
|
||||
}
|
||||
|
||||
if err := d.server.Start(ctx); err != nil {
|
||||
slog.Error("gateway error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
|
||||
@@ -13,11 +14,14 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/orchestration"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
||||
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
|
||||
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/media"
|
||||
memorypkg "github.com/nextlevelbuilder/goclaw/internal/memory"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
@@ -50,6 +54,7 @@ func wireExtras(
|
||||
appCfg *config.Config,
|
||||
sandboxMgr sandbox.Manager,
|
||||
redisClient any, // nil when built without -tags redis or when Redis is unconfigured
|
||||
domainBus eventbus.DomainEventBus,
|
||||
) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) {
|
||||
// 1. Build cache instances (in-memory or Redis depending on build tags)
|
||||
agentCtxCache, userCtxCache := makeCaches(redisClient)
|
||||
@@ -129,6 +134,12 @@ func wireExtras(
|
||||
skillAccessStore = sas
|
||||
}
|
||||
|
||||
// V3 auto-inject: create AutoInjector if episodic store is available.
|
||||
var autoInjector memorypkg.AutoInjector
|
||||
if stores.Episodic != nil {
|
||||
autoInjector = memorypkg.NewAutoInjector(stores.Episodic, stores.EvolutionMetrics)
|
||||
}
|
||||
|
||||
resolver := agent.NewManagedResolver(agent.ResolverDeps{
|
||||
AgentStore: stores.Agents,
|
||||
ProviderStore: stores.Providers,
|
||||
@@ -170,6 +181,9 @@ func wireExtras(
|
||||
BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs,
|
||||
SkillTenantCfgs: stores.SkillTenantCfgs,
|
||||
Workspace: workspace,
|
||||
AutoInjector: autoInjector,
|
||||
EvolutionMetricsStore: stores.EvolutionMetrics,
|
||||
DomainBus: domainBus,
|
||||
OnEvent: func(event agent.AgentEvent) {
|
||||
// Sign /v1/files/ and /v1/media/ URLs in content before delivery.
|
||||
// Sessions store clean paths; signing happens only at delivery time.
|
||||
@@ -290,6 +304,24 @@ func wireExtras(
|
||||
slog.Info("memory layering enabled")
|
||||
}
|
||||
|
||||
// V3: Wire episodic store + evolution metrics on memory tools (search + expand)
|
||||
if stores.Episodic != nil {
|
||||
if searchTool, ok := toolsReg.Get("memory_search"); ok {
|
||||
if mst, ok := searchTool.(*tools.MemorySearchTool); ok {
|
||||
mst.SetEpisodicStore(stores.Episodic)
|
||||
if stores.EvolutionMetrics != nil {
|
||||
mst.SetEvolutionMetricsStore(stores.EvolutionMetrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
if expandTool, ok := toolsReg.Get("memory_expand"); ok {
|
||||
if met, ok := expandTool.(*tools.MemoryExpandTool); ok {
|
||||
met.SetEpisodicStore(stores.Episodic)
|
||||
}
|
||||
}
|
||||
slog.Info("v3 episodic memory wired to tools")
|
||||
}
|
||||
|
||||
// Wire knowledge graph store on KG tool + hint in memory_search results
|
||||
if stores.KnowledgeGraph != nil {
|
||||
if kgTool, ok := toolsReg.Get("knowledge_graph_search"); ok {
|
||||
@@ -306,6 +338,45 @@ func wireExtras(
|
||||
slog.Info("knowledge graph tool wired (Postgres)")
|
||||
}
|
||||
|
||||
// Wire vault tools and interceptors (conditional on vault store availability)
|
||||
wireVault(stores, toolsReg, workspace, domainBus)
|
||||
|
||||
// Wire delegate tool for inter-agent delegation via agent_links.
|
||||
if stores.AgentLinks != nil && stores.Agents != nil {
|
||||
delegateRunFn := func(ctx context.Context, req tools.DelegateRequest) (tools.DelegateResult, error) {
|
||||
loop, err := agentRouter.Get(ctx, req.ToAgentKey)
|
||||
if err != nil {
|
||||
return tools.DelegateResult{}, fmt.Errorf("target agent %q not found: %w", req.ToAgentKey, err)
|
||||
}
|
||||
sessionKey := fmt.Sprintf("delegate:%s:%s:%s",
|
||||
req.FromAgentID.String()[:8], req.ToAgentKey, req.DelegationID)
|
||||
|
||||
// Link delegate trace to parent trace
|
||||
delegateCtx := tracing.WithDelegateParentTraceID(ctx, tracing.TraceIDFromContext(ctx))
|
||||
|
||||
runReq := agent.RunRequest{
|
||||
RunID: uuid.New().String(),
|
||||
SessionKey: sessionKey,
|
||||
Message: req.Task,
|
||||
UserID: req.UserID,
|
||||
Channel: "delegate",
|
||||
RunKind: "delegate",
|
||||
DelegationID: req.DelegationID,
|
||||
ParentAgentID: req.FromAgentKey,
|
||||
}
|
||||
result, err := loop.Run(delegateCtx, runReq)
|
||||
if err != nil {
|
||||
return tools.DelegateResult{}, err
|
||||
}
|
||||
cr := orchestration.CaptureFromRunResult(result, 0)
|
||||
return tools.DelegateResult{Content: cr.Content, Media: cr.Media}, nil
|
||||
}
|
||||
delegateTool := tools.NewDelegateTool(stores.AgentLinks, stores.Agents, domainBus, delegateRunFn)
|
||||
delegateTool.SetMsgBus(msgBus)
|
||||
toolsReg.Register(delegateTool)
|
||||
slog.Info("delegate tool wired")
|
||||
}
|
||||
|
||||
// --- Cache invalidation event subscribers ---
|
||||
|
||||
// Context file cache: invalidate on agent/context data changes
|
||||
@@ -440,6 +511,12 @@ func wireExtras(
|
||||
})
|
||||
}
|
||||
|
||||
// V3 evolution: daily suggestion engine + weekly evaluation cron (background goroutine).
|
||||
if stores.EvolutionMetrics != nil && stores.EvolutionSuggestions != nil {
|
||||
sugEngine := agent.NewSuggestionEngine(stores.EvolutionMetrics, stores.EvolutionSuggestions)
|
||||
go runEvolutionCron(stores, sugEngine)
|
||||
}
|
||||
|
||||
// Register team tools (team_tasks + workspace interceptor) if team store is available.
|
||||
var postTurn tools.PostTurnProcessor
|
||||
if stores.Teams != nil && stores.Agents != nil {
|
||||
|
||||
@@ -28,15 +28,17 @@ func loopbackAddr(host string, port int) string {
|
||||
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
func registerProviders(registry *providers.Registry, cfg *config.Config) {
|
||||
func registerProviders(registry *providers.Registry, cfg *config.Config, modelReg providers.ModelRegistry) {
|
||||
if cfg.Providers.Anthropic.APIKey != "" {
|
||||
registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey,
|
||||
providers.WithAnthropicBaseURL(cfg.Providers.Anthropic.APIBase)))
|
||||
providers.WithAnthropicBaseURL(cfg.Providers.Anthropic.APIBase),
|
||||
providers.WithAnthropicRegistry(modelReg)))
|
||||
slog.Info("registered provider", "name", "anthropic")
|
||||
}
|
||||
|
||||
if cfg.Providers.OpenAI.APIKey != "" {
|
||||
registry.Register(providers.NewOpenAIProvider("openai", cfg.Providers.OpenAI.APIKey, cfg.Providers.OpenAI.APIBase, "gpt-4o"))
|
||||
registry.Register(providers.NewOpenAIProvider("openai", cfg.Providers.OpenAI.APIKey, cfg.Providers.OpenAI.APIBase, "gpt-4o").
|
||||
WithRegistry(modelReg))
|
||||
slog.Info("registered provider", "name", "openai")
|
||||
}
|
||||
|
||||
@@ -268,7 +270,7 @@ func jsonToStringMap(data json.RawMessage) map[string]string {
|
||||
// gatewayAddr is used to inject GoClaw MCP bridge for Claude CLI providers.
|
||||
// mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config.
|
||||
// cfg provides fallback api_base values from config/env when DB providers have none set.
|
||||
func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config) {
|
||||
func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config, modelReg providers.ModelRegistry) {
|
||||
dbProviders, err := provStore.ListAllProviders(context.Background())
|
||||
if err != nil {
|
||||
slog.Warn("failed to load providers from DB", "error", err)
|
||||
@@ -343,7 +345,8 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
|
||||
case store.ProviderAnthropicNative:
|
||||
registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey,
|
||||
providers.WithAnthropicName(p.Name),
|
||||
providers.WithAnthropicBaseURL(p.APIBase)))
|
||||
providers.WithAnthropicBaseURL(p.APIBase),
|
||||
providers.WithAnthropicRegistry(modelReg)))
|
||||
case store.ProviderDashScope:
|
||||
registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, ""))
|
||||
case store.ProviderBailian:
|
||||
|
||||
+19
-1
@@ -85,6 +85,7 @@ func setupToolRegistry(
|
||||
// Memory tools — PG-backed; always registered (PG memory is always available)
|
||||
toolsReg.Register(tools.NewMemorySearchTool())
|
||||
toolsReg.Register(tools.NewMemoryGetTool())
|
||||
toolsReg.Register(tools.NewMemoryExpandTool())
|
||||
toolsReg.Register(tools.NewKnowledgeGraphSearchTool())
|
||||
slog.Info("memory + knowledge graph tools registered (PG-backed)")
|
||||
|
||||
@@ -214,7 +215,12 @@ func setupToolRegistry(
|
||||
if execTool, ok := toolsReg.Get("exec"); ok {
|
||||
if et, ok := execTool.(*tools.ExecTool); ok {
|
||||
et.DenyPaths(dataDir, ".goclaw/")
|
||||
et.AllowPathExemptions(".goclaw/skills-store/", filepath.Join(dataDir, "skills-store")+"/")
|
||||
// Allow skills execution: master-tenant skills-store + all tenant-scoped skills-store dirs.
|
||||
et.AllowPathExemptions(
|
||||
".goclaw/skills-store/",
|
||||
filepath.Join(dataDir, "skills-store")+"/",
|
||||
filepath.Join(dataDir, "tenants")+"/",
|
||||
)
|
||||
// Harden: block access to internal workspace files via shell commands.
|
||||
// Prevents `cat ../config.json`, `cat memory.db` etc. from user workspaces.
|
||||
et.DenyPaths(
|
||||
@@ -389,6 +395,18 @@ func setupMemoryEmbeddings(
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wire embedding provider into vault store for semantic document search.
|
||||
if pgStores.Vault != nil {
|
||||
pgStores.Vault.SetEmbeddingProvider(embProvider)
|
||||
slog.Info("vault embeddings enabled", "provider", embProvider.Name())
|
||||
}
|
||||
|
||||
// V3: Wire embedding provider into episodic store for semantic search.
|
||||
if pgStores.Episodic != nil {
|
||||
pgStores.Episodic.SetEmbeddingProvider(embProvider)
|
||||
slog.Info("episodic embeddings enabled", "provider", embProvider.Name())
|
||||
}
|
||||
} else {
|
||||
slog.Warn("memory embeddings disabled (no API key), chunks stored without vectors")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -13,11 +12,66 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
orch "github.com/nextlevelbuilder/goclaw/internal/orchestration"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// makeDelegateAnnounceCallback returns the batch callback used by tools.NewAnnounceQueue.
|
||||
// Extracted from runGateway to keep the main function concise.
|
||||
func makeDelegateAnnounceCallback(
|
||||
subagentMgr *tools.SubagentManager,
|
||||
msgBus *bus.MessageBus,
|
||||
) func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
|
||||
return func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
|
||||
roster := subagentMgr.RosterForParent(meta.ParentAgent)
|
||||
content := tools.FormatBatchedAnnounce(items, roster)
|
||||
senderID := fmt.Sprintf("subagent:batch-%d", len(items))
|
||||
label := items[0].Label
|
||||
if len(items) > 1 {
|
||||
label = fmt.Sprintf("%d tasks", len(items))
|
||||
}
|
||||
batchMeta := map[string]string{
|
||||
tools.MetaOriginChannel: meta.OriginChannel,
|
||||
tools.MetaOriginPeerKind: meta.OriginPeerKind,
|
||||
tools.MetaParentAgent: meta.ParentAgent,
|
||||
tools.MetaSubagentLabel: label,
|
||||
tools.MetaOriginTraceID: meta.OriginTraceID,
|
||||
tools.MetaOriginRootSpanID: meta.OriginRootSpanID,
|
||||
}
|
||||
if meta.OriginLocalKey != "" {
|
||||
batchMeta[tools.MetaOriginLocalKey] = meta.OriginLocalKey
|
||||
}
|
||||
if meta.OriginSessionKey != "" {
|
||||
batchMeta[tools.MetaOriginSessionKey] = meta.OriginSessionKey
|
||||
}
|
||||
// Collect media from all items in the batch.
|
||||
var batchMedia []bus.MediaFile
|
||||
for _, item := range items {
|
||||
batchMedia = append(batchMedia, item.Media...)
|
||||
}
|
||||
// Notify clients that leader is processing team results
|
||||
// (bridges UI gap between last task.completed and announce run.started).
|
||||
bus.BroadcastForTenant(msgBus, protocol.EventTeamLeaderProcessing, meta.OriginTenantID, map[string]any{
|
||||
"agentId": meta.ParentAgent,
|
||||
"tasks": len(items),
|
||||
})
|
||||
|
||||
msgBus.PublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: senderID,
|
||||
ChatID: meta.OriginChatID,
|
||||
Content: content,
|
||||
UserID: meta.OriginUserID,
|
||||
TenantID: meta.OriginTenantID,
|
||||
Metadata: batchMeta,
|
||||
Media: batchMedia,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// subagentAnnounceEntry holds one subagent completion result waiting to be announced.
|
||||
type subagentAnnounceEntry struct {
|
||||
Label string
|
||||
@@ -47,60 +101,17 @@ type subagentAnnounceRouting struct {
|
||||
OutMeta map[string]string
|
||||
}
|
||||
|
||||
// subagentAnnounceQueue is a producer-consumer queue per parent session.
|
||||
// Multiple subagent goroutines enqueue entries; one processor drains and merges.
|
||||
type subagentAnnounceQueue struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
entries []subagentAnnounceEntry
|
||||
}
|
||||
// subagentAnnounceQueue uses BatchQueue for producer-consumer synchronization.
|
||||
var subagentAnnounceQueue orch.BatchQueue[subagentAnnounceEntry]
|
||||
|
||||
// subagentAnnounceQueues maps sessionKey → queue. Cleaned up when queue finishes.
|
||||
var subagentAnnounceQueues sync.Map
|
||||
|
||||
func getOrCreateSubagentAnnounceQueue(key string) *subagentAnnounceQueue {
|
||||
v, _ := subagentAnnounceQueues.LoadOrStore(key, &subagentAnnounceQueue{})
|
||||
return v.(*subagentAnnounceQueue)
|
||||
}
|
||||
|
||||
// enqueueSubagentAnnounce adds a result to the queue. Returns (queue, isProcessor).
|
||||
// If isProcessor=true, the caller must run processSubagentAnnounceLoop.
|
||||
func enqueueSubagentAnnounce(key string, entry subagentAnnounceEntry) (*subagentAnnounceQueue, bool) {
|
||||
q := getOrCreateSubagentAnnounceQueue(key)
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.entries = append(q.entries, entry)
|
||||
if q.running {
|
||||
return q, false
|
||||
}
|
||||
q.running = true
|
||||
return q, true
|
||||
}
|
||||
|
||||
func (q *subagentAnnounceQueue) drain() []subagentAnnounceEntry {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
out := q.entries
|
||||
q.entries = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// tryFinish atomically checks for pending entries and marks the queue idle.
|
||||
func (q *subagentAnnounceQueue) tryFinish(key string) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.entries) > 0 {
|
||||
return false
|
||||
}
|
||||
q.running = false
|
||||
subagentAnnounceQueues.Delete(key)
|
||||
return true
|
||||
// enqueueSubagentAnnounce adds a result to the queue. Returns isProcessor.
|
||||
func enqueueSubagentAnnounce(key string, entry subagentAnnounceEntry) bool {
|
||||
return subagentAnnounceQueue.Enqueue(key, entry)
|
||||
}
|
||||
|
||||
// processSubagentAnnounceLoop drains entries, builds merged announce, schedules to parent.
|
||||
func processSubagentAnnounceLoop(
|
||||
ctx context.Context,
|
||||
q *subagentAnnounceQueue,
|
||||
r subagentAnnounceRouting,
|
||||
roster tools.SubagentRoster,
|
||||
subagentMgr *tools.SubagentManager,
|
||||
@@ -116,14 +127,14 @@ func processSubagentAnnounceLoop(
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
q.tryFinish(r.QueueKey)
|
||||
subagentAnnounceQueue.TryFinish(r.QueueKey)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
entries := q.drain()
|
||||
entries := subagentAnnounceQueue.Drain(r.QueueKey)
|
||||
if len(entries) == 0 {
|
||||
if q.tryFinish(r.QueueKey) {
|
||||
if subagentAnnounceQueue.TryFinish(r.QueueKey) {
|
||||
return
|
||||
}
|
||||
// Brief sleep to avoid tight spin when entries arrive between drain and tryFinish.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// wireExtraTools registers cron, heartbeat, session, message tools and aliases
|
||||
// onto the tool registry after setupToolRegistry() and setupSkillsSystem() have run.
|
||||
// Returns the heartbeat tool (needed for later wiring) and the hasMemory flag.
|
||||
func wireExtraTools(
|
||||
pgStores *store.Stores,
|
||||
toolsReg *tools.Registry,
|
||||
msgBus *bus.MessageBus,
|
||||
workspace string,
|
||||
dataDir string,
|
||||
agentCfg config.AgentDefaults,
|
||||
globalSkillsDir string,
|
||||
builtinSkillsDir string,
|
||||
) (heartbeatTool *tools.HeartbeatTool, hasMemory bool) {
|
||||
// DateTime tool (precise time for cron scheduling, memory timestamps, etc.)
|
||||
toolsReg.Register(tools.NewDateTimeTool())
|
||||
|
||||
// Cron tool (agent-facing)
|
||||
toolsReg.Register(tools.NewCronTool(pgStores.Cron))
|
||||
slog.Info("cron tool registered")
|
||||
|
||||
// Heartbeat tool (agent-facing)
|
||||
heartbeatTool = tools.NewHeartbeatTool(pgStores.Heartbeats, pgStores.ConfigPermissions)
|
||||
heartbeatTool.SetAgentStore(pgStores.Agents)
|
||||
toolsReg.Register(heartbeatTool)
|
||||
slog.Info("heartbeat tool registered")
|
||||
|
||||
// Session tools (list, status, history, send)
|
||||
toolsReg.Register(tools.NewSessionsListTool())
|
||||
toolsReg.Register(tools.NewSessionStatusTool())
|
||||
toolsReg.Register(tools.NewSessionsHistoryTool())
|
||||
toolsReg.Register(tools.NewSessionsSendTool())
|
||||
|
||||
// Message tool (send to channels)
|
||||
toolsReg.Register(tools.NewMessageTool(workspace, agentCfg.RestrictToWorkspace))
|
||||
// Group members tool (list members in group chats)
|
||||
toolsReg.Register(tools.NewListGroupMembersTool())
|
||||
slog.Info("session + message tools registered")
|
||||
|
||||
// Register legacy tool aliases (backward-compat names from policy.go).
|
||||
for alias, canonical := range tools.LegacyToolAliases() {
|
||||
toolsReg.RegisterAlias(alias, canonical)
|
||||
}
|
||||
|
||||
// Register Claude Code tool aliases so Claude Code skills work without modification.
|
||||
for alias, canonical := range map[string]string{
|
||||
"Read": "read_file",
|
||||
"Write": "write_file",
|
||||
"Edit": "edit",
|
||||
"Bash": "exec",
|
||||
"WebFetch": "web_fetch",
|
||||
"WebSearch": "web_search",
|
||||
"Agent": "spawn",
|
||||
"Skill": "use_skill",
|
||||
"ToolSearch": "mcp_tool_search",
|
||||
} {
|
||||
toolsReg.RegisterAlias(alias, canonical)
|
||||
}
|
||||
slog.Info("tool aliases registered", "count", len(toolsReg.Aliases()))
|
||||
|
||||
// Allow read_file and list_files to access skills directories and CLI workspaces.
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
skillsAllowPaths := []string{globalSkillsDir, builtinSkillsDir, filepath.Join(dataDir, "tenants")}
|
||||
if homeDir != "" {
|
||||
skillsAllowPaths = append(skillsAllowPaths, filepath.Join(homeDir, ".agents", "skills"))
|
||||
}
|
||||
if pgStores.Skills != nil {
|
||||
skillsAllowPaths = append(skillsAllowPaths, pgStores.Skills.Dirs()...)
|
||||
}
|
||||
if readTool, ok := toolsReg.Get("read_file"); ok {
|
||||
if pa, ok := readTool.(tools.PathAllowable); ok {
|
||||
pa.AllowPaths(skillsAllowPaths...)
|
||||
pa.AllowPaths(filepath.Join(dataDir, "cli-workspaces"))
|
||||
}
|
||||
}
|
||||
if listTool, ok := toolsReg.Get("list_files"); ok {
|
||||
if pa, ok := listTool.(tools.PathAllowable); ok {
|
||||
pa.AllowPaths(skillsAllowPaths...)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory tools are PG-backed; always available.
|
||||
hasMemory = true
|
||||
|
||||
// Wire SessionStoreAware + BusAware on session tools
|
||||
for _, name := range []string{"sessions_list", "session_status", "sessions_history", "sessions_send"} {
|
||||
if t, ok := toolsReg.Get(name); ok {
|
||||
if sa, ok := t.(tools.SessionStoreAware); ok {
|
||||
sa.SetSessionStore(pgStores.Sessions)
|
||||
}
|
||||
if ba, ok := t.(tools.BusAware); ok {
|
||||
ba.SetMessageBus(msgBus)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wire BusAware on message tool
|
||||
if t, ok := toolsReg.Get("message"); ok {
|
||||
if ba, ok := t.(tools.BusAware); ok {
|
||||
ba.SetMessageBus(msgBus)
|
||||
}
|
||||
}
|
||||
|
||||
return heartbeatTool, hasMemory
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/vault"
|
||||
)
|
||||
|
||||
// wireVault wires Knowledge Vault tools and interceptors into the tool registry.
|
||||
// All wiring is skipped if stores.Vault is nil.
|
||||
// Pattern mirrors wireExtras KG wiring: register tools, set stores, set interceptors.
|
||||
func wireVault(stores *store.Stores, toolsReg *tools.Registry, workspace string, bus eventbus.DomainEventBus) {
|
||||
if stores.Vault == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Register vault tools — these are always available when vault store is present.
|
||||
vaultSearchTool := tools.NewVaultSearchTool()
|
||||
vaultLinkTool := tools.NewVaultLinkTool()
|
||||
vaultBacklinksTool := tools.NewVaultBacklinksTool()
|
||||
toolsReg.Register(vaultSearchTool)
|
||||
toolsReg.Register(vaultLinkTool)
|
||||
toolsReg.Register(vaultBacklinksTool)
|
||||
|
||||
// Wire vault store onto link/backlinks tools.
|
||||
vaultLinkTool.SetVaultStore(stores.Vault)
|
||||
vaultBacklinksTool.SetVaultStore(stores.Vault)
|
||||
|
||||
// Build VaultSearchService: fan-out across vault + KG (episodic store pending impl).
|
||||
// EpisodicStore is nil until a PG implementation exists.
|
||||
searchSvc := vault.NewVaultSearchService(stores.Vault, nil, stores.KnowledgeGraph)
|
||||
vaultSearchTool.SetSearchService(searchSvc)
|
||||
|
||||
// Build shared VaultInterceptor for read/write tool vault registration.
|
||||
vaultIntc := tools.NewVaultInterceptor(stores.Vault, workspace, bus)
|
||||
|
||||
// Wire interceptor into write_file (registers doc on write).
|
||||
if writeTool, ok := toolsReg.Get("write_file"); ok {
|
||||
if wt, ok := writeTool.(*tools.WriteFileTool); ok {
|
||||
wt.SetVaultInterceptor(vaultIntc)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire interceptor into read_file (lazy hash sync on read).
|
||||
if readTool, ok := toolsReg.Get("read_file"); ok {
|
||||
if rt, ok := readTool.(*tools.ReadFileTool); ok {
|
||||
rt.SetVaultInterceptor(vaultIntc)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire interceptor into media generation tools.
|
||||
if imgTool, ok := toolsReg.Get("create_image"); ok {
|
||||
if it, ok := imgTool.(*tools.CreateImageTool); ok {
|
||||
it.SetVaultInterceptor(vaultIntc)
|
||||
}
|
||||
}
|
||||
if vidTool, ok := toolsReg.Get("create_video"); ok {
|
||||
if vt, ok := vidTool.(*tools.CreateVideoTool); ok {
|
||||
vt.SetVaultInterceptor(vaultIntc)
|
||||
}
|
||||
}
|
||||
if audTool, ok := toolsReg.Get("create_audio"); ok {
|
||||
if at, ok := audTool.(*tools.CreateAudioTool); ok {
|
||||
at.SetVaultInterceptor(vaultIntc)
|
||||
}
|
||||
}
|
||||
if ttsTool, ok := toolsReg.Get("tts"); ok {
|
||||
if tt, ok := ttsTool.(*tools.TtsTool); ok {
|
||||
tt.SetVaultInterceptor(vaultIntc)
|
||||
}
|
||||
}
|
||||
if editTool, ok := toolsReg.Get("edit"); ok {
|
||||
if et, ok := editTool.(*tools.EditTool); ok {
|
||||
et.SetVaultInterceptor(vaultIntc)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("vault tools registered", "tools", "vault_search,vault_link,vault_backlinks,create_image,create_video,create_audio,tts,edit")
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
func modelsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "models",
|
||||
Short: "List available AI models and providers",
|
||||
}
|
||||
cmd.AddCommand(modelsListCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
type modelEntry struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func modelsListCmd() *cobra.Command {
|
||||
var jsonOutput bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured models and providers",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cfgPath := resolveConfigPath()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
entries := buildModelList(cfg)
|
||||
|
||||
if jsonOutput {
|
||||
data, _ := json.MarshalIndent(entries, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, "PROVIDER\tMODEL\tSTATUS\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", e.Provider, e.Model, e.Status)
|
||||
}
|
||||
tw.Flush()
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildModelList(cfg *config.Config) []modelEntry {
|
||||
var entries []modelEntry
|
||||
|
||||
// Default agent model
|
||||
entries = append(entries, modelEntry{
|
||||
Provider: cfg.Agents.Defaults.Provider,
|
||||
Model: cfg.Agents.Defaults.Model,
|
||||
Status: "default",
|
||||
})
|
||||
|
||||
// Per-agent overrides
|
||||
for id, spec := range cfg.Agents.List {
|
||||
if spec.Model != "" {
|
||||
entries = append(entries, modelEntry{
|
||||
Provider: spec.Provider,
|
||||
Model: spec.Model,
|
||||
Status: "agent:" + id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Available providers
|
||||
type providerCheck struct {
|
||||
name string
|
||||
hasKey bool
|
||||
}
|
||||
providers := []providerCheck{
|
||||
{"anthropic", cfg.Providers.Anthropic.APIKey != ""},
|
||||
{"openai", cfg.Providers.OpenAI.APIKey != ""},
|
||||
{"openrouter", cfg.Providers.OpenRouter.APIKey != ""},
|
||||
{"gemini", cfg.Providers.Gemini.APIKey != ""},
|
||||
{"groq", cfg.Providers.Groq.APIKey != ""},
|
||||
{"deepseek", cfg.Providers.DeepSeek.APIKey != ""},
|
||||
{"mistral", cfg.Providers.Mistral.APIKey != ""},
|
||||
{"xai", cfg.Providers.XAI.APIKey != ""},
|
||||
}
|
||||
for _, p := range providers {
|
||||
if p.hasKey {
|
||||
entries = append(entries, modelEntry{
|
||||
Provider: p.name,
|
||||
Model: "(any)",
|
||||
Status: "available",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
+4
-1
@@ -165,7 +165,10 @@ func runOnboard() {
|
||||
fmt.Println(" 1. Start the gateway:")
|
||||
fmt.Printf(" source %s && ./goclaw\n", envPath)
|
||||
fmt.Println()
|
||||
fmt.Println(" 2. Open the dashboard to complete setup:")
|
||||
fmt.Println(" 2. Run the configuration wizard:")
|
||||
fmt.Println(" goclaw setup")
|
||||
fmt.Println()
|
||||
fmt.Println(" 3. Or open the dashboard:")
|
||||
fmt.Printf(" http://localhost:%s\n", port)
|
||||
fmt.Println()
|
||||
fmt.Println(" The setup wizard will guide you through:")
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func providersCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "providers",
|
||||
Short: "Manage LLM providers (requires running gateway)",
|
||||
}
|
||||
cmd.AddCommand(providersListCmd())
|
||||
cmd.AddCommand(providersAddCmd())
|
||||
cmd.AddCommand(providersUpdateCmd())
|
||||
cmd.AddCommand(providersDeleteCmd())
|
||||
cmd.AddCommand(providersVerifyCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// httpProviderFull is a detailed provider representation from the HTTP API.
|
||||
type httpProviderFull struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProviderType string `json:"provider_type"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
}
|
||||
|
||||
func providersListCmd() *cobra.Command {
|
||||
var jsonOutput bool
|
||||
var showModels bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured providers",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runProvidersList(jsonOutput, showModels)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
|
||||
cmd.Flags().BoolVar(&showModels, "models", false, "also show available models per provider")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runProvidersList(jsonOutput, showModels bool) {
|
||||
providers, err := fetchProviders()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput && !showModels {
|
||||
data, _ := json.MarshalIndent(providers, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
if len(providers) == 0 {
|
||||
fmt.Println("No providers configured.")
|
||||
return
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, "ID\tNAME\tTYPE\tENABLED\n")
|
||||
for _, p := range providers {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%v\n", p.ID, p.Name, p.ProviderType, p.Enabled)
|
||||
}
|
||||
tw.Flush()
|
||||
|
||||
if showModels {
|
||||
fmt.Println()
|
||||
for _, p := range providers {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("── Models for %s (%s) ──\n", p.Name, p.ProviderType)
|
||||
resp, err := gatewayHTTPGet("/v1/providers/" + url.PathEscape(p.ID) + "/models")
|
||||
if err != nil {
|
||||
fmt.Printf(" Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
raw, _ := json.Marshal(resp["models"])
|
||||
var models []httpProviderModel
|
||||
if err := json.Unmarshal(raw, &models); err != nil {
|
||||
fmt.Printf(" Error parsing models: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if len(models) == 0 {
|
||||
fmt.Println(" (no models available)")
|
||||
continue
|
||||
}
|
||||
for _, m := range models {
|
||||
fmt.Printf(" %s\n", m.ID)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func providersAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new provider (interactive)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runProvidersAdd()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runProvidersAdd() {
|
||||
fmt.Println("── Add Provider ──")
|
||||
fmt.Println()
|
||||
|
||||
// Step 1: Provider type
|
||||
typeOptions := []SelectOption[string]{
|
||||
{"Anthropic", "anthropic"},
|
||||
{"OpenAI", "openai"},
|
||||
{"OpenRouter", "openrouter"},
|
||||
{"DashScope (Alibaba)", "dashscope"},
|
||||
{"OpenAI-compatible", "openai-compat"},
|
||||
}
|
||||
providerType, err := promptSelect("Provider type", typeOptions, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Name
|
||||
name, err := promptString("Provider name", "", providerType)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: API key
|
||||
apiKey, err := promptPassword("API key", "will be encrypted at rest")
|
||||
if err != nil || apiKey == "" {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: Base URL (pre-fill per type, editable)
|
||||
defaultURL := defaultBaseURL(providerType)
|
||||
baseURL := ""
|
||||
if providerType == "openai-compat" {
|
||||
baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", defaultURL)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"provider_type": providerType,
|
||||
"api_key": apiKey,
|
||||
"enabled": true,
|
||||
}
|
||||
if baseURL != "" {
|
||||
body["base_url"] = baseURL
|
||||
}
|
||||
|
||||
resp, err := gatewayHTTPPost("/v1/providers", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating provider: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
providerID, _ := resp["id"].(string)
|
||||
fmt.Printf("\nProvider %q (%s) created.\n", name, providerType)
|
||||
|
||||
// Offer to verify
|
||||
if providerID != "" {
|
||||
verify, err := promptConfirm("Verify connection now?", true)
|
||||
if err == nil && verify {
|
||||
runProviderVerify(providerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func providersUpdateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a provider",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runProvidersUpdate(args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runProvidersUpdate(providerID string) {
|
||||
// Fetch current provider
|
||||
resp, err := gatewayHTTPGet("/v1/providers/" + url.PathEscape(providerID))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
currentName, _ := resp["name"].(string)
|
||||
currentType, _ := resp["provider_type"].(string)
|
||||
|
||||
fmt.Printf("Updating provider: %s (%s)\n", currentName, currentType)
|
||||
fmt.Println("Press Enter to keep current value.")
|
||||
fmt.Println()
|
||||
|
||||
name, err := promptString("Name", "", currentName)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := promptPassword("New API key (leave empty to keep current)", "")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{"name": name}
|
||||
if apiKey != "" {
|
||||
body["api_key"] = apiKey
|
||||
}
|
||||
|
||||
_, err = gatewayHTTPPut("/v1/providers/"+url.PathEscape(providerID), body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Provider updated.")
|
||||
}
|
||||
|
||||
func providersDeleteCmd() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a provider",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
if !force {
|
||||
confirmed, err := promptConfirm(fmt.Sprintf("Delete provider %q?", args[0]), false)
|
||||
if err != nil || !confirmed {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := gatewayHTTPDelete("/v1/providers/" + url.PathEscape(args[0])); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Provider %q deleted.\n", args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&force, "force", false, "skip confirmation")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func providersVerifyCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "verify <id>",
|
||||
Short: "Verify provider connectivity and list models",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runProviderVerify(args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runProviderVerify(providerID string) {
|
||||
fmt.Print("Verifying provider... ")
|
||||
resp, err := gatewayHTTPPost("/v1/providers/"+url.PathEscape(providerID)+"/verify", nil)
|
||||
if err != nil {
|
||||
fmt.Printf("FAILED\n %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := resp["success"].(bool); ok {
|
||||
fmt.Println("OK")
|
||||
// Show available models
|
||||
raw, _ := json.Marshal(resp["models"])
|
||||
var models []httpProviderModel
|
||||
if json.Unmarshal(raw, &models) == nil && len(models) > 0 {
|
||||
fmt.Printf(" Available models: %d\n", len(models))
|
||||
limit := 10
|
||||
for i, m := range models {
|
||||
if i >= limit {
|
||||
fmt.Printf(" ... and %d more\n", len(models)-limit)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" - %s\n", m.ID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg, _ := resp["error"].(string)
|
||||
fmt.Printf("FAILED\n %s\n", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultBaseURL returns the default API base URL for a provider type.
|
||||
func defaultBaseURL(providerType string) string {
|
||||
switch providerType {
|
||||
case "anthropic":
|
||||
return "https://api.anthropic.com"
|
||||
case "openai":
|
||||
return "https://api.openai.com/v1"
|
||||
case "openrouter":
|
||||
return "https://openrouter.ai/api/v1"
|
||||
case "dashscope":
|
||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/backup"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
func restoreCmd() *cobra.Command {
|
||||
var (
|
||||
skipDB bool
|
||||
skipFiles bool
|
||||
force bool
|
||||
dryRun bool
|
||||
fromS3 string
|
||||
listS3 bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore [archive-path]",
|
||||
Short: "Restore system from a backup archive (database + filesystem)",
|
||||
Long: `Restores GoClaw from a .tar.gz backup archive produced by 'goclaw backup'.
|
||||
|
||||
WARNING: This is a destructive operation. The database will be overwritten.
|
||||
Requires --force flag to proceed. Stop the gateway before restoring.
|
||||
|
||||
Use --list-s3 to list available S3 backups.
|
||||
Use --from-s3 <key> to download and restore from S3.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(resolveConfigPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
// --list-s3: list available S3 backups and exit.
|
||||
if listS3 {
|
||||
return listS3Backups(cmd.Context(), cfg)
|
||||
}
|
||||
|
||||
// --from-s3: download backup from S3 to a temp file, then restore.
|
||||
archivePath := ""
|
||||
if len(args) > 0 {
|
||||
archivePath = args[0]
|
||||
}
|
||||
|
||||
if fromS3 != "" {
|
||||
tmpPath, err := downloadFromS3(cmd.Context(), cfg, fromS3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 download: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
archivePath = tmpPath
|
||||
fmt.Printf("Downloaded from S3: %s → %s\n", fromS3, tmpPath)
|
||||
}
|
||||
|
||||
if archivePath == "" {
|
||||
return fmt.Errorf("archive-path required (or use --from-s3 <key>)")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(archivePath); err != nil {
|
||||
return fmt.Errorf("archive not found: %s", archivePath)
|
||||
}
|
||||
|
||||
dsn := cfg.Database.PostgresDSN
|
||||
|
||||
if !dryRun && !force {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --force flag is required for restore (destructive operation).")
|
||||
fmt.Fprintln(os.Stderr, " Use --dry-run to preview what would be restored.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !dryRun {
|
||||
// Check for active DB connections before destructive restore.
|
||||
if dsn != "" && !skipDB {
|
||||
conns, connErr := backup.CheckActiveConnections(cmd.Context(), dsn)
|
||||
if connErr == nil && conns > 0 {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"WARNING: %d active connection(s) detected on the database.\n"+
|
||||
" Stop the gateway and all clients before restoring.\n", conns)
|
||||
if !force {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Restoring from: %s\n", archivePath)
|
||||
if skipDB {
|
||||
fmt.Println(" database: skipped")
|
||||
}
|
||||
if skipFiles {
|
||||
fmt.Println(" filesystem: skipped")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Dry-run: inspecting archive %s\n", archivePath)
|
||||
}
|
||||
|
||||
opts := backup.RestoreOptions{
|
||||
ArchivePath: archivePath,
|
||||
DSN: dsn,
|
||||
DataDir: cfg.ResolvedDataDir(),
|
||||
WorkspacePath: cfg.WorkspacePath(),
|
||||
DryRun: dryRun,
|
||||
SkipDB: skipDB,
|
||||
SkipFiles: skipFiles,
|
||||
Force: force,
|
||||
ProgressFn: func(phase, detail string) {
|
||||
fmt.Printf(" [%s] %s\n", phase, detail)
|
||||
},
|
||||
}
|
||||
|
||||
result, err := backup.Restore(cmd.Context(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if dryRun {
|
||||
fmt.Println("Dry-run complete (no changes made):")
|
||||
} else {
|
||||
fmt.Println("Restore complete:")
|
||||
}
|
||||
fmt.Printf(" manifest version : %d\n", result.ManifestVersion)
|
||||
fmt.Printf(" schema version : %d\n", result.SchemaVersion)
|
||||
fmt.Printf(" database restored: %v\n", result.DatabaseRestored)
|
||||
fmt.Printf(" files extracted : %d (%d MB)\n",
|
||||
result.FilesExtracted, result.BytesExtracted>>20)
|
||||
|
||||
for _, w := range result.Warnings {
|
||||
fmt.Printf(" WARNING: %s\n", w)
|
||||
}
|
||||
|
||||
if result.DatabaseRestored {
|
||||
fmt.Println("\nNext steps: run 'goclaw migrate up' if schema version was older than current.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&skipDB, "skip-db", false, "skip database restore (filesystem only)")
|
||||
cmd.Flags().BoolVar(&skipFiles, "skip-files", false, "skip filesystem restore (database only)")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "required: confirm destructive restore operation")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "inspect archive and show restore plan without executing")
|
||||
cmd.Flags().StringVar(&fromS3, "from-s3", "", "download and restore from this S3 key (e.g. backups/backup-20260409.tar.gz)")
|
||||
cmd.Flags().BoolVar(&listS3, "list-s3", false, "list available backups in S3 and exit")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// loadS3Client opens the DB, reads S3 config from config_secrets, and returns a client.
|
||||
func loadS3Client(ctx context.Context, cfg *config.Config) (*backup.S3Client, error) {
|
||||
if cfg.Database.PostgresDSN == "" {
|
||||
return nil, fmt.Errorf("postgres DSN not configured; set GOCLAW_POSTGRES_DSN")
|
||||
}
|
||||
db, err := sql.Open("pgx", cfg.Database.PostgresDSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
|
||||
secrets := pg.NewPGConfigSecretsStore(db, encKey)
|
||||
|
||||
s3cfg, err := backup.LoadS3Config(ctx, secrets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load s3 config: %w", err)
|
||||
}
|
||||
if s3cfg == nil {
|
||||
return nil, fmt.Errorf("s3 not configured — save credentials via API or CLI first")
|
||||
}
|
||||
return backup.NewS3Client(s3cfg)
|
||||
}
|
||||
|
||||
// listS3Backups prints available S3 backups to stdout.
|
||||
func listS3Backups(ctx context.Context, cfg *config.Config) error {
|
||||
client, err := loadS3Client(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := client.ListBackups(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list s3 backups: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No backups found in S3.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%-60s %10s %s\n", "Key", "Size", "Last Modified")
|
||||
fmt.Printf("%-60s %10s %s\n", "---", "----", "-------------")
|
||||
for _, e := range entries {
|
||||
fmt.Printf("%-60s %10s %s\n",
|
||||
e.Key,
|
||||
formatBackupSize(e.Size),
|
||||
e.LastModified.Format("2006-01-02 15:04:05 UTC"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFromS3 downloads the given S3 key to a temp file and returns its path.
|
||||
func downloadFromS3(ctx context.Context, cfg *config.Config, key string) (string, error) {
|
||||
client, err := loadS3Client(ctx, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "goclaw-s3-restore-*.tar.gz")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
defer tmp.Close()
|
||||
|
||||
fmt.Printf("Downloading s3://%s ...\n", key)
|
||||
if err := client.Download(ctx, key, tmp); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", err
|
||||
}
|
||||
return tmp.Name(), nil
|
||||
}
|
||||
|
||||
// formatBackupSize returns a human-readable size string.
|
||||
func formatBackupSize(b int64) string {
|
||||
switch {
|
||||
case b >= 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(b)/(1<<30))
|
||||
case b >= 1<<20:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/(1<<20))
|
||||
default:
|
||||
return fmt.Sprintf("%d KB", b>>10)
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -36,14 +36,19 @@ func init() {
|
||||
rootCmd.AddCommand(agentCmd())
|
||||
rootCmd.AddCommand(doctorCmd())
|
||||
rootCmd.AddCommand(configCmd())
|
||||
rootCmd.AddCommand(modelsCmd())
|
||||
rootCmd.AddCommand(providersCmd())
|
||||
rootCmd.AddCommand(channelsCmd())
|
||||
rootCmd.AddCommand(cronCmd())
|
||||
rootCmd.AddCommand(skillsCmd())
|
||||
rootCmd.AddCommand(sessionsCmd())
|
||||
rootCmd.AddCommand(migrateCmd())
|
||||
rootCmd.AddCommand(upgradeCmd())
|
||||
rootCmd.AddCommand(backupCmd())
|
||||
rootCmd.AddCommand(restoreCmd())
|
||||
rootCmd.AddCommand(tenantBackupCmd())
|
||||
rootCmd.AddCommand(tenantRestoreCmd())
|
||||
rootCmd.AddCommand(authCmd())
|
||||
rootCmd.AddCommand(setupCmd())
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// setupAgentStep guides the user through agent creation.
|
||||
func setupAgentStep() {
|
||||
fmt.Println("── Step 2: Agent ──")
|
||||
fmt.Println()
|
||||
|
||||
agents, err := fetchAgentList()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching agents: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(agents) > 0 {
|
||||
fmt.Printf(" Found %d existing agent(s):\n", len(agents))
|
||||
for _, a := range agents {
|
||||
fmt.Printf(" - %s (%s / %s)\n", a.AgentKey, a.Provider, a.Model)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
create, err := promptConfirm("Create another agent?", false)
|
||||
if err != nil || !create {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" No agents yet. Let's create your first one.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
createAgent()
|
||||
}
|
||||
|
||||
func createAgent() {
|
||||
agentKey, err := promptString("Agent key (slug)", "e.g. assistant, coder", "assistant")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
displayName, err := promptString("Display name", "", agentKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
typeOptions := []SelectOption[string]{
|
||||
{"Open (per-user context)", "open"},
|
||||
{"Predefined (shared context)", "predefined"},
|
||||
}
|
||||
agentType, err := promptSelect("Agent type", typeOptions, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch providers for selection
|
||||
providers, err := fetchProviders()
|
||||
if err != nil || len(providers) == 0 {
|
||||
fmt.Println(" No providers available. Add a provider first.")
|
||||
return
|
||||
}
|
||||
|
||||
providerOptions := make([]SelectOption[string], len(providers))
|
||||
for i, p := range providers {
|
||||
providerOptions[i] = SelectOption[string]{
|
||||
Label: fmt.Sprintf("%s (%s)", p.Name, p.ProviderType),
|
||||
Value: p.ID,
|
||||
}
|
||||
}
|
||||
providerID, err := promptSelect("Provider", providerOptions, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
model, err := selectModel(providerID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"agent_key": agentKey,
|
||||
"display_name": displayName,
|
||||
"agent_type": agentType,
|
||||
"provider": findProviderType(providers, providerID),
|
||||
"model": model,
|
||||
}
|
||||
|
||||
_, err = gatewayHTTPPost("/v1/agents", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Agent %q created (%s).\n\n", agentKey, model)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// setupChannelStep optionally guides the user through channel setup.
|
||||
func setupChannelStep() {
|
||||
fmt.Println("── Step 3: Channel (optional) ──")
|
||||
fmt.Println()
|
||||
|
||||
setup, err := promptConfirm("Set up a messaging channel?", false)
|
||||
if err != nil || !setup {
|
||||
fmt.Println(" Skipped.")
|
||||
fmt.Println()
|
||||
return
|
||||
}
|
||||
|
||||
typeOptions := []SelectOption[string]{
|
||||
{"Telegram", "telegram"},
|
||||
{"Discord", "discord"},
|
||||
{"Slack", "slack"},
|
||||
}
|
||||
channelType, err := promptSelect("Channel type", typeOptions, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
name, err := promptString("Instance name", "", channelType+"-bot")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Credentials per type
|
||||
creds := map[string]string{}
|
||||
switch channelType {
|
||||
case "telegram":
|
||||
token, err := promptPassword("Bot token", "from @BotFather")
|
||||
if err != nil || token == "" {
|
||||
return
|
||||
}
|
||||
creds["token"] = token
|
||||
case "discord":
|
||||
token, err := promptPassword("Bot token", "from Discord Developer Portal")
|
||||
if err != nil || token == "" {
|
||||
return
|
||||
}
|
||||
creds["token"] = token
|
||||
case "slack":
|
||||
token, err := promptPassword("Bot token", "xoxb-...")
|
||||
if err != nil || token == "" {
|
||||
return
|
||||
}
|
||||
creds["token"] = token
|
||||
secret, err := promptPassword("Signing secret", "")
|
||||
if err != nil || secret == "" {
|
||||
return
|
||||
}
|
||||
creds["signing_secret"] = secret
|
||||
}
|
||||
|
||||
// Bind to agent
|
||||
agents, err := fetchAgentList()
|
||||
if err != nil || len(agents) == 0 {
|
||||
fmt.Fprintf(os.Stderr, " No agents found. Create an agent first.\n")
|
||||
return
|
||||
}
|
||||
|
||||
agentOptions := make([]SelectOption[string], len(agents))
|
||||
for i, a := range agents {
|
||||
agentOptions[i] = SelectOption[string]{
|
||||
Label: fmt.Sprintf("%s (%s)", a.AgentKey, a.DisplayName),
|
||||
Value: a.ID,
|
||||
}
|
||||
}
|
||||
agentID, err := promptSelect("Bind to agent", agentOptions, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"channel_type": channelType,
|
||||
"agent_id": agentID,
|
||||
"enabled": true,
|
||||
"credentials": creds,
|
||||
}
|
||||
|
||||
_, err = gatewayHTTPPost("/v1/channels/instances", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Channel %q (%s) created.\n\n", name, channelType)
|
||||
fmt.Println(" Note: For Zalo, Feishu, WhatsApp — use the Web Dashboard.")
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func setupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Configuration wizard — providers, agents, channels",
|
||||
Long: "Interactive setup for providers, models, agents, and channels. Requires a running gateway.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requireRunningGatewayHTTP()
|
||||
runSetup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runSetup() {
|
||||
fmt.Println()
|
||||
fmt.Println("╭──────────────────────────────────╮")
|
||||
fmt.Println("│ GoClaw — Setup Wizard │")
|
||||
fmt.Println("╰──────────────────────────────────╯")
|
||||
fmt.Println()
|
||||
|
||||
// Step 1: Providers
|
||||
setupProviderStep()
|
||||
|
||||
// Step 2: Agent
|
||||
setupAgentStep()
|
||||
|
||||
// Step 3: Channel (optional)
|
||||
setupChannelStep()
|
||||
|
||||
// Summary
|
||||
printSetupSummary()
|
||||
}
|
||||
|
||||
func printSetupSummary() {
|
||||
fmt.Println()
|
||||
fmt.Println("── Setup Complete ──")
|
||||
fmt.Println()
|
||||
|
||||
// Show what was configured
|
||||
providers, _ := fetchProviders()
|
||||
agents, _ := fetchAgentList()
|
||||
|
||||
if len(providers) > 0 {
|
||||
fmt.Printf(" Providers: %d configured\n", len(providers))
|
||||
for _, p := range providers {
|
||||
fmt.Printf(" - %s (%s)\n", p.Name, p.ProviderType)
|
||||
}
|
||||
}
|
||||
|
||||
if len(agents) > 0 {
|
||||
fmt.Printf(" Agents: %d configured\n", len(agents))
|
||||
for _, a := range agents {
|
||||
fmt.Printf(" - %s (%s)\n", a.AgentKey, a.Model)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
base := resolveGatewayBaseURL()
|
||||
fmt.Printf(" Dashboard: %s\n", base)
|
||||
fmt.Println()
|
||||
fmt.Println("Run 'goclaw setup' again anytime to add more.")
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
)
|
||||
|
||||
// setupProviderStep guides the user through provider configuration.
|
||||
func setupProviderStep() {
|
||||
fmt.Println("── Step 1: Providers ──")
|
||||
fmt.Println()
|
||||
|
||||
providers, err := fetchProviders()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching providers: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(providers) > 0 {
|
||||
fmt.Printf(" Found %d existing provider(s):\n", len(providers))
|
||||
for _, p := range providers {
|
||||
fmt.Printf(" - %s (%s)\n", p.Name, p.ProviderType)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
addMore, err := promptConfirm("Add another provider?", false)
|
||||
if err != nil || !addMore {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" No providers configured yet. Let's add one.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
for {
|
||||
addProvider()
|
||||
|
||||
another, err := promptConfirm("Add another provider?", false)
|
||||
if err != nil || !another {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addProvider() {
|
||||
typeOptions := []SelectOption[string]{
|
||||
{"Anthropic", "anthropic"},
|
||||
{"OpenAI", "openai"},
|
||||
{"OpenRouter", "openrouter"},
|
||||
{"DashScope (Alibaba)", "dashscope"},
|
||||
{"OpenAI-compatible", "openai-compat"},
|
||||
}
|
||||
providerType, err := promptSelect("Provider type", typeOptions, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
name, err := promptString("Provider name", "", providerType)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := promptPassword("API key", "will be encrypted at rest")
|
||||
if err != nil || apiKey == "" {
|
||||
fmt.Println(" Skipped (no API key).")
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := ""
|
||||
if providerType == "openai-compat" {
|
||||
baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"provider_type": providerType,
|
||||
"api_key": apiKey,
|
||||
"enabled": true,
|
||||
}
|
||||
if baseURL != "" {
|
||||
body["base_url"] = baseURL
|
||||
}
|
||||
|
||||
resp, err := gatewayHTTPPost("/v1/providers", body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
providerID, _ := resp["id"].(string)
|
||||
fmt.Printf(" Provider %q created.\n", name)
|
||||
|
||||
// Auto-verify
|
||||
if providerID != "" {
|
||||
fmt.Print(" Verifying... ")
|
||||
verifyResp, err := gatewayHTTPPost("/v1/providers/"+url.PathEscape(providerID)+"/verify", nil)
|
||||
if err != nil {
|
||||
fmt.Printf("FAILED (%v)\n", err)
|
||||
return
|
||||
}
|
||||
if ok, _ := verifyResp["success"].(bool); ok {
|
||||
fmt.Println("OK")
|
||||
raw, _ := json.Marshal(verifyResp["models"])
|
||||
var models []httpProviderModel
|
||||
if json.Unmarshal(raw, &models) == nil {
|
||||
fmt.Printf(" %d models available.\n", len(models))
|
||||
}
|
||||
} else {
|
||||
msg, _ := verifyResp["error"].(string)
|
||||
fmt.Printf("FAILED (%s)\n", msg)
|
||||
fmt.Println(" You can update the API key later with 'goclaw providers update'.")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
+53
-2
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/tabwriter"
|
||||
@@ -26,10 +27,18 @@ func skillsCmd() *cobra.Command {
|
||||
|
||||
func skillsListCmd() *cobra.Command {
|
||||
var jsonOutput bool
|
||||
var agentID string
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all available skills",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// If --agent specified and gateway is running, use HTTP API
|
||||
if agentID != "" && isGatewayReachable() {
|
||||
runSkillsListHTTP(agentID, jsonOutput)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: filesystem-based skill listing
|
||||
loader := loadSkillsLoader()
|
||||
allSkills := loader.ListSkills(context.Background())
|
||||
|
||||
@@ -48,14 +57,15 @@ func skillsListCmd() *cobra.Command {
|
||||
fmt.Fprintf(tw, "NAME\tSOURCE\tDESCRIPTION\n")
|
||||
for _, s := range allSkills {
|
||||
desc := s.Description
|
||||
if len(desc) > 60 {
|
||||
desc = desc[:57] + "..."
|
||||
if runes := []rune(desc); len(runes) > 60 {
|
||||
desc = string(runes[:57]) + "..."
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.Name, s.Source, desc)
|
||||
}
|
||||
tw.Flush()
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&agentID, "agent", "", "agent ID to list skills for (uses gateway API)")
|
||||
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
|
||||
return cmd
|
||||
}
|
||||
@@ -87,6 +97,47 @@ func skillsShowCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// runSkillsListHTTP fetches skills for a specific agent from the gateway API.
|
||||
func runSkillsListHTTP(agentID string, jsonOutput bool) {
|
||||
resp, err := gatewayHTTPGet("/v1/agents/" + url.PathEscape(agentID) + "/skills")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
data, _ := json.MarshalIndent(resp, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(resp["skills"])
|
||||
var skills []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &skills); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing skills: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(skills) == 0 {
|
||||
fmt.Println("No skills found for this agent.")
|
||||
return
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, "NAME\tDESCRIPTION\n")
|
||||
for _, s := range skills {
|
||||
desc := s.Description
|
||||
if runes := []rune(desc); len(runes) > 60 {
|
||||
desc = string(runes[:57]) + "..."
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\n", s.Name, desc)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func loadSkillsLoader() *skills.Loader {
|
||||
cfgPath := resolveConfigPath()
|
||||
cfg, _ := config.Load(cfgPath)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/backup"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
|
||||
)
|
||||
|
||||
func tenantBackupCmd() *cobra.Command {
|
||||
var (
|
||||
outputPath string
|
||||
tenantSlug string
|
||||
tenantID string
|
||||
uploadS3 bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "tenant-backup",
|
||||
Short: "Create a tenant-scoped backup (database rows + filesystem)",
|
||||
Long: "Exports all DB rows belonging to a tenant + workspace/data dirs as a .tar.gz archive.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(resolveConfigPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
// Tenant backup is PG-only — SQLite edition has only master tenant
|
||||
if cfg.Database.StorageBackend == "sqlite" {
|
||||
return fmt.Errorf("tenant backup is not available in Lite edition (single tenant). Use 'goclaw backup' for full system backup")
|
||||
}
|
||||
|
||||
tid, slug, db, err := resolveTenantForCLI(cmd, cfg, tenantID, tenantSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if outputPath == "" {
|
||||
ts := time.Now().UTC().Format("20060102-150405")
|
||||
outputPath = fmt.Sprintf("./tenant-backup-%s-%s.tar.gz", slug, ts)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting tenant backup → %s\n", outputPath)
|
||||
fmt.Printf(" tenant : %s (%s)\n", slug, tid)
|
||||
|
||||
dataDir := config.TenantDataDir(cfg.ResolvedDataDir(), tid, slug)
|
||||
wsDir := config.TenantWorkspace(cfg.WorkspacePath(), tid, slug)
|
||||
|
||||
opts := backup.TenantBackupOptions{
|
||||
DB: db,
|
||||
TenantID: tid,
|
||||
TenantSlug: slug,
|
||||
DataDir: dataDir,
|
||||
WorkspacePath: wsDir,
|
||||
OutputPath: outputPath,
|
||||
CreatedBy: "cli",
|
||||
SchemaVersion: int(upgrade.RequiredSchemaVersion),
|
||||
ProgressFn: func(phase, detail string) {
|
||||
fmt.Printf(" [%s] %s\n", phase, detail)
|
||||
},
|
||||
}
|
||||
|
||||
manifest, err := backup.TenantBackup(cmd.Context(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tenant backup failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nTenant backup complete: %s\n", outputPath)
|
||||
fmt.Printf(" tenant : %s\n", manifest.TenantSlug)
|
||||
fmt.Printf(" schema version : %d\n", manifest.SchemaVersion)
|
||||
fmt.Printf(" tables : %d\n", len(manifest.TableCounts))
|
||||
fmt.Printf(" files : %d\n", manifest.Stats.FilesystemFiles)
|
||||
|
||||
if uploadS3 {
|
||||
return tenantS3Upload(cmd, cfg, outputPath)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&tenantSlug, "tenant", "", "tenant slug to back up")
|
||||
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "tenant UUID (alternative to --tenant)")
|
||||
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output path for .tar.gz")
|
||||
cmd.Flags().BoolVar(&uploadS3, "upload-s3", false, "upload backup to S3 after creation")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
// resolveTenantForCLI opens the DB, looks up the tenant by slug or UUID,
|
||||
// and returns the resolved tenant ID, slug, and an open *sql.DB (caller must close).
|
||||
func resolveTenantForCLI(cmd *cobra.Command, cfg *config.Config, rawID, slug string) (uuid.UUID, string, *sql.DB, error) {
|
||||
if rawID == "" && slug == "" {
|
||||
return uuid.Nil, "", nil, fmt.Errorf("--tenant <slug> or --tenant-id <uuid> is required")
|
||||
}
|
||||
|
||||
dsn := cfg.Database.PostgresDSN
|
||||
if dsn == "" {
|
||||
return uuid.Nil, "", nil, fmt.Errorf("GOCLAW_POSTGRES_DSN not configured")
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return uuid.Nil, "", nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
ts := pg.NewPGTenantStore(db)
|
||||
|
||||
if rawID != "" {
|
||||
tid, err := uuid.Parse(rawID)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return uuid.Nil, "", nil, fmt.Errorf("invalid tenant-id: %w", err)
|
||||
}
|
||||
tenant, err := ts.GetTenant(cmd.Context(), tid)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return uuid.Nil, "", nil, fmt.Errorf("tenant not found: %w", err)
|
||||
}
|
||||
return tenant.ID, tenant.Slug, db, nil
|
||||
}
|
||||
|
||||
tenant, err := ts.GetTenantBySlug(cmd.Context(), slug)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return uuid.Nil, "", nil, fmt.Errorf("tenant %q not found: %w", slug, err)
|
||||
}
|
||||
return tenant.ID, tenant.Slug, db, nil
|
||||
}
|
||||
|
||||
// tenantS3Upload uploads a local archive to S3 using the system S3 config.
|
||||
func tenantS3Upload(cmd *cobra.Command, cfg *config.Config, archivePath string) error {
|
||||
if err := uploadBackupToS3(cmd.Context(), cfg, archivePath, Version); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nS3 upload failed: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/backup"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
func tenantRestoreCmd() *cobra.Command {
|
||||
var (
|
||||
tenantSlug string
|
||||
tenantID string
|
||||
mode string
|
||||
force bool
|
||||
dryRun bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "tenant-restore <archive-path>",
|
||||
Short: "Restore a tenant from a backup archive",
|
||||
Long: `Restores a tenant from a .tar.gz archive produced by 'goclaw tenant-backup'.
|
||||
|
||||
Modes:
|
||||
upsert (default) — INSERT … ON CONFLICT DO NOTHING. Non-destructive.
|
||||
replace — Delete existing tenant data first, then INSERT. Requires --force.
|
||||
new — Create a new tenant and import data under the new tenant ID.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
archivePath := args[0]
|
||||
|
||||
// Tenant restore is PG-only
|
||||
cfg, cfgErr := config.Load(resolveConfigPath())
|
||||
if cfgErr == nil && cfg.Database.StorageBackend == "sqlite" {
|
||||
return fmt.Errorf("tenant restore is not available in Lite edition (single tenant). Use 'goclaw restore' for full system restore")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(archivePath); err != nil {
|
||||
return fmt.Errorf("archive not found: %s", archivePath)
|
||||
}
|
||||
if mode == "replace" && !dryRun && !force {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --force is required for replace mode (destructive operation).")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(resolveConfigPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
// For "new" mode the tenant may not exist yet — allow lookup failure.
|
||||
tid, slug, db, lookupErr := resolveTenantForCLI(cmd, cfg, tenantID, tenantSlug)
|
||||
if lookupErr != nil && mode != "new" {
|
||||
return lookupErr
|
||||
}
|
||||
if db != nil {
|
||||
defer db.Close()
|
||||
}
|
||||
|
||||
dataDir := config.TenantDataDir(cfg.ResolvedDataDir(), tid, slug)
|
||||
wsDir := config.TenantWorkspace(cfg.WorkspacePath(), tid, slug)
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf("Dry-run: inspecting archive %s\n", archivePath)
|
||||
} else {
|
||||
fmt.Printf("Restoring tenant (%s) from: %s\n", slug, archivePath)
|
||||
fmt.Printf(" mode: %s\n", mode)
|
||||
}
|
||||
|
||||
opts := backup.TenantRestoreOptions{
|
||||
DB: db,
|
||||
ArchivePath: archivePath,
|
||||
TenantID: tid,
|
||||
TenantSlug: slug,
|
||||
DataDir: dataDir,
|
||||
WorkspacePath: wsDir,
|
||||
Mode: mode,
|
||||
Force: force,
|
||||
DryRun: dryRun,
|
||||
ProgressFn: func(phase, detail string) {
|
||||
fmt.Printf(" [%s] %s\n", phase, detail)
|
||||
},
|
||||
}
|
||||
|
||||
result, err := backup.TenantRestore(cmd.Context(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tenant restore failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if dryRun {
|
||||
fmt.Println("Dry-run complete (no changes made).")
|
||||
} else {
|
||||
fmt.Println("Tenant restore complete:")
|
||||
fmt.Printf(" tenant_id : %s\n", result.TenantID)
|
||||
fmt.Printf(" tables restored: %d\n", len(result.TablesRestored))
|
||||
fmt.Printf(" files extracted: %d\n", result.FilesExtracted)
|
||||
}
|
||||
for _, w := range result.Warnings {
|
||||
fmt.Printf(" WARNING: %s\n", w)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&tenantSlug, "tenant", "", "target tenant slug")
|
||||
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "target tenant UUID")
|
||||
cmd.Flags().StringVar(&mode, "mode", "upsert", "restore mode: upsert, replace, new")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "required for replace mode")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "inspect archive without making changes")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build tui
|
||||
|
||||
package cmd
|
||||
|
||||
import "fmt"
|
||||
|
||||
// tuiProgressBar renders a text-based progress bar: [●●●○○] 3/5
|
||||
func tuiProgressBar(current, total int) string {
|
||||
bar := ""
|
||||
for i := 0; i < total; i++ {
|
||||
if i < current {
|
||||
bar += tuiStepDone + " "
|
||||
} else if i == current {
|
||||
bar += tuiStepCurrent + " "
|
||||
} else {
|
||||
bar += tuiStepPending + " "
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s %d/%d", bar, current, total)
|
||||
}
|
||||
|
||||
// tuiHeader renders a styled header with progress.
|
||||
func tuiHeader(title string, step, total int) string {
|
||||
header := tuiTitleStyle.Render(title)
|
||||
progress := tuiProgressBar(step, total)
|
||||
return fmt.Sprintf("\n%s %s\n", header, progress)
|
||||
}
|
||||
|
||||
// tuiResult renders a success/fail line.
|
||||
func tuiResult(ok bool, msg string) string {
|
||||
if ok {
|
||||
return tuiSuccessStyle.Render(" ✓ ") + msg
|
||||
}
|
||||
return tuiErrorStyle.Render(" ✗ ") + msg
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build tui
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// onboardTUIModel is the Bubble Tea model for the onboard wizard.
|
||||
type onboardTUIModel struct {
|
||||
steps []string
|
||||
currentStep int
|
||||
done bool
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func newOnboardTUIModel() onboardTUIModel {
|
||||
return onboardTUIModel{
|
||||
steps: []string{"Database", "Test Connection", "Migrations", "Keys", "Save", "Summary"},
|
||||
}
|
||||
}
|
||||
|
||||
func (m onboardTUIModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m onboardTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if m.currentStep < len(m.steps)-1 {
|
||||
m.currentStep++
|
||||
} else {
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m onboardTUIModel) View() string {
|
||||
if m.quitting {
|
||||
return tuiMutedStyle.Render("Onboard cancelled.\n")
|
||||
}
|
||||
if m.done {
|
||||
return tuiSuccessStyle.Render("Onboard complete! Run 'goclaw setup' next.\n")
|
||||
}
|
||||
|
||||
s := tuiHeader("GoClaw — Onboard", m.currentStep, len(m.steps))
|
||||
s += "\n"
|
||||
|
||||
for i, step := range m.steps {
|
||||
indicator := tuiStepPending
|
||||
if i < m.currentStep {
|
||||
indicator = tuiStepDone
|
||||
} else if i == m.currentStep {
|
||||
indicator = tuiStepCurrent
|
||||
}
|
||||
s += fmt.Sprintf(" %s %s\n", indicator, step)
|
||||
}
|
||||
|
||||
s += "\n"
|
||||
s += tuiBoxStyle.Render(fmt.Sprintf("Step: %s\n\nPress Enter to continue, 'q' to quit.",
|
||||
m.steps[m.currentStep]))
|
||||
s += "\n"
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// runOnboardTUI runs the Bubble Tea onboard wizard (tui build).
|
||||
func runOnboardTUI() {
|
||||
p := tea.NewProgram(newOnboardTUIModel())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("TUI error: %v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !tui
|
||||
|
||||
package cmd
|
||||
|
||||
// runOnboardTUI is a no-op when built without tui tag.
|
||||
// The existing huh-based onboard flow in onboard.go is used directly.
|
||||
func runOnboardTUI() {
|
||||
// no-op: onboard.go already handles the full flow with huh forms
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build tui
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// setupTUIModel is the Bubble Tea model for the setup wizard.
|
||||
type setupTUIModel struct {
|
||||
steps []string
|
||||
currentStep int
|
||||
done bool
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func newSetupTUIModel() setupTUIModel {
|
||||
return setupTUIModel{
|
||||
steps: []string{"Providers", "Agent", "Channel", "Summary"},
|
||||
}
|
||||
}
|
||||
|
||||
func (m setupTUIModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m setupTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if m.currentStep < len(m.steps)-1 {
|
||||
m.currentStep++
|
||||
} else {
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "s": // skip step
|
||||
if m.currentStep < len(m.steps)-1 {
|
||||
m.currentStep++
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m setupTUIModel) View() string {
|
||||
if m.quitting {
|
||||
return tuiMutedStyle.Render("Setup cancelled.\n")
|
||||
}
|
||||
if m.done {
|
||||
return tuiSuccessStyle.Render("Setup complete!\n")
|
||||
}
|
||||
|
||||
s := tuiHeader("GoClaw — Setup Wizard", m.currentStep, len(m.steps))
|
||||
s += "\n"
|
||||
|
||||
// Step indicator
|
||||
for i, step := range m.steps {
|
||||
indicator := tuiStepPending
|
||||
if i < m.currentStep {
|
||||
indicator = tuiStepDone
|
||||
} else if i == m.currentStep {
|
||||
indicator = tuiStepCurrent
|
||||
}
|
||||
s += fmt.Sprintf(" %s %s\n", indicator, step)
|
||||
}
|
||||
|
||||
s += "\n"
|
||||
s += tuiBoxStyle.Render(fmt.Sprintf("Step: %s\n\nPress Enter to configure, 's' to skip, 'q' to quit.",
|
||||
m.steps[m.currentStep]))
|
||||
s += "\n"
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// runSetupTUI runs the Bubble Tea setup wizard (tui build).
|
||||
// Falls through to the huh-based wizard for actual configuration since
|
||||
// each step uses huh forms for data collection.
|
||||
func runSetupTUI() {
|
||||
p := tea.NewProgram(newSetupTUIModel())
|
||||
model, err := p.Run()
|
||||
if err != nil {
|
||||
fmt.Printf("TUI error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
m := model.(setupTUIModel)
|
||||
if m.quitting {
|
||||
return
|
||||
}
|
||||
|
||||
// After TUI navigation, run the actual huh-based setup steps
|
||||
runSetup()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !tui
|
||||
|
||||
package cmd
|
||||
|
||||
// runSetupTUI delegates to the huh-based setup when built without tui tag.
|
||||
func runSetupTUI() {
|
||||
runSetup()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build tui
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
tuiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||
tuiSuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
tuiErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
tuiMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
tuiBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2)
|
||||
tuiStepDone = tuiSuccessStyle.Render("●")
|
||||
tuiStepCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render("◐")
|
||||
tuiStepPending = tuiMutedStyle.Render("○")
|
||||
)
|
||||
Reference in new issue
Block a user