diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-17 13:02:59 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-17 13:02:59 -0500 |
| commit | 91dff1309ceb0729bc3fdde61878f81fd3df4eec (patch) | |
| tree | 89a9d11e69e4e00c0b6b9a9877831fa8ed2807ac /Omni | |
| parent | 32c2bb198007ab85095c14be544cfca9d083a7cd (diff) | |
Add subagent system for Ava
Enables orchestrator to spawn specialized subagents for focused tasks:
- WebCrawler: web search + page reading (haiku, fast)
- CodeReviewer: code analysis tools (sonnet, thorough)
- DataExtractor: structured data extraction (haiku)
- Researcher: combined web + codebase research (sonnet)
Key features:
- spawn_subagent tool with role-based tool selection
- Per-subagent resource limits (timeout, cost, tokens)
- Structured output with citations (claim, source_url, quote)
- Separate API keys for OpenRouter vs Kagi
- Efficiency-focused system prompts
Defaults: 200k tokens, $1.00 cost cap, 600s timeout, 20 iterations
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Agent/Subagent.hs | 516 | ||||
| -rw-r--r-- | Omni/Agent/Subagent/DESIGN.md | 352 | ||||
| -rw-r--r-- | Omni/Agent/Telegram.hs | 13 |
3 files changed, 880 insertions, 1 deletions
diff --git a/Omni/Agent/Subagent.hs b/Omni/Agent/Subagent.hs new file mode 100644 index 0000000..c8e56d5 --- /dev/null +++ b/Omni/Agent/Subagent.hs @@ -0,0 +1,516 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Subagent system for spawning specialized agents. +-- +-- Enables the orchestrator (Ava) to delegate focused tasks to specialized +-- subagents that run with their own tool sets and resource limits. +-- +-- Key features: +-- - Role-based tool selection (WebCrawler, CodeReviewer, etc.) +-- - Per-subagent resource limits (timeout, cost, tokens) +-- - Structured result format with confidence scores +-- - No sub-subagent spawning (hierarchical control) +-- +-- : out omni-agent-subagent +-- : dep aeson +-- : dep async +module Omni.Agent.Subagent + ( -- * Types + SubagentRole (..), + SubagentConfig (..), + SubagentResult (..), + SubagentStatus (..), + SubagentCallbacks (..), + + -- * Execution + runSubagent, + runSubagentWithCallbacks, + + -- * Tool + spawnSubagentTool, + + -- * Role-specific tools + SubagentApiKeys (..), + toolsForRole, + modelForRole, + systemPromptForRole, + + -- * Defaults + defaultSubagentConfig, + defaultCallbacks, + + -- * Testing + main, + test, + ) +where + +import Alpha +import Data.Aeson ((.!=), (.:), (.:?), (.=)) +import qualified Data.Aeson as Aeson +import qualified Data.Text as Text +import qualified Data.Time.Clock as Clock +import qualified Omni.Agent.Engine as Engine +import qualified Omni.Agent.Provider as Provider +import qualified Omni.Agent.Tools as Tools +import qualified Omni.Agent.Tools.WebReader as WebReader +import qualified Omni.Agent.Tools.WebSearch as WebSearch +import qualified Omni.Test as Test + +main :: IO () +main = Test.run test + +test :: Test.Tree +test = + Test.group + "Omni.Agent.Subagent" + [ Test.unit "SubagentRole JSON roundtrip" <| do + let roles = [WebCrawler, CodeReviewer, DataExtractor, Researcher] + forM_ roles <| \role -> + case Aeson.decode (Aeson.encode role) of + Nothing -> Test.assertFailure ("Failed to decode role: " <> show role) + Just decoded -> decoded Test.@=? role, + Test.unit "SubagentConfig JSON roundtrip" <| do + let cfg = defaultSubagentConfig WebCrawler "test task" + case Aeson.decode (Aeson.encode cfg) of + Nothing -> Test.assertFailure "Failed to decode SubagentConfig" + Just decoded -> subagentTask decoded Test.@=? "test task", + Test.unit "SubagentResult JSON roundtrip" <| do + let result = + SubagentResult + { subagentOutput = Aeson.object ["data" .= ("test" :: Text)], + subagentSummary = "Test summary", + subagentConfidence = 0.85, + subagentTokensUsed = 1000, + subagentCostCents = 0.5, + subagentDuration = 30, + subagentIterations = 3, + subagentStatus = SubagentSuccess + } + case Aeson.decode (Aeson.encode result) of + Nothing -> Test.assertFailure "Failed to decode SubagentResult" + Just decoded -> subagentSummary decoded Test.@=? "Test summary", + Test.unit "SubagentStatus JSON roundtrip" <| do + let statuses = + [ SubagentSuccess, + SubagentTimeout, + SubagentCostExceeded, + SubagentError "test error" + ] + forM_ statuses <| \status -> + case Aeson.decode (Aeson.encode status) of + Nothing -> Test.assertFailure ("Failed to decode status: " <> show status) + Just decoded -> decoded Test.@=? status, + Test.unit "toolsForRole WebCrawler has web tools" <| do + let keys = SubagentApiKeys "test-openrouter-key" (Just "test-kagi-key") + let tools = toolsForRole WebCrawler keys + let names = map Engine.toolName tools + ("web_search" `elem` names) Test.@=? True + ("read_webpages" `elem` names) Test.@=? True, + Test.unit "toolsForRole CodeReviewer has code tools" <| do + let keys = SubagentApiKeys "test-openrouter-key" Nothing + let tools = toolsForRole CodeReviewer keys + let names = map Engine.toolName tools + ("read_file" `elem` names) Test.@=? True + ("search_codebase" `elem` names) Test.@=? True, + Test.unit "modelForRole returns appropriate models" <| do + modelForRole WebCrawler Test.@=? "anthropic/claude-3-haiku" + modelForRole CodeReviewer Test.@=? "anthropic/claude-sonnet-4" + modelForRole Researcher Test.@=? "anthropic/claude-sonnet-4", + Test.unit "defaultSubagentConfig has sensible defaults" <| do + let cfg = defaultSubagentConfig WebCrawler "task" + subagentTimeout cfg Test.@=? 600 + subagentMaxCost cfg Test.@=? 100.0 + subagentMaxTokens cfg Test.@=? 200000 + subagentMaxIterations cfg Test.@=? 20, + Test.unit "spawnSubagentTool has correct name" <| do + let keys = SubagentApiKeys "test-openrouter-key" (Just "test-kagi-key") + let tool = spawnSubagentTool keys + Engine.toolName tool Test.@=? "spawn_subagent" + ] + +data SubagentRole + = WebCrawler + | CodeReviewer + | DataExtractor + | Researcher + | CustomRole Text + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON SubagentRole where + toJSON WebCrawler = Aeson.String "web_crawler" + toJSON CodeReviewer = Aeson.String "code_reviewer" + toJSON DataExtractor = Aeson.String "data_extractor" + toJSON Researcher = Aeson.String "researcher" + toJSON (CustomRole name) = Aeson.String name + +instance Aeson.FromJSON SubagentRole where + parseJSON = Aeson.withText "SubagentRole" parseRole + where + parseRole "web_crawler" = pure WebCrawler + parseRole "code_reviewer" = pure CodeReviewer + parseRole "data_extractor" = pure DataExtractor + parseRole "researcher" = pure Researcher + parseRole name = pure (CustomRole name) + +data SubagentConfig = SubagentConfig + { subagentRole :: SubagentRole, + subagentTask :: Text, + subagentModel :: Maybe Text, + subagentTimeout :: Int, + subagentMaxCost :: Double, + subagentMaxTokens :: Int, + subagentMaxIterations :: Int, + subagentExtendedThinking :: Bool, + subagentContext :: Maybe Text + } + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON SubagentConfig where + toJSON c = + Aeson.object + <| catMaybes + [ Just ("role" .= subagentRole c), + Just ("task" .= subagentTask c), + ("model" .=) </ subagentModel c, + Just ("timeout" .= subagentTimeout c), + Just ("max_cost_cents" .= subagentMaxCost c), + Just ("max_tokens" .= subagentMaxTokens c), + Just ("max_iterations" .= subagentMaxIterations c), + Just ("extended_thinking" .= subagentExtendedThinking c), + ("context" .=) </ subagentContext c + ] + +instance Aeson.FromJSON SubagentConfig where + parseJSON = + Aeson.withObject "SubagentConfig" <| \v -> + (SubagentConfig </ (v .: "role")) + <*> (v .: "task") + <*> (v .:? "model") + <*> (v .:? "timeout" .!= 600) + <*> (v .:? "max_cost_cents" .!= 50.0) + <*> (v .:? "max_tokens" .!= 100000) + <*> (v .:? "max_iterations" .!= 20) + <*> (v .:? "extended_thinking" .!= False) + <*> (v .:? "context") + +data SubagentResult = SubagentResult + { subagentOutput :: Aeson.Value, + subagentSummary :: Text, + subagentConfidence :: Double, + subagentTokensUsed :: Int, + subagentCostCents :: Double, + subagentDuration :: Int, + subagentIterations :: Int, + subagentStatus :: SubagentStatus + } + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON SubagentResult + +instance Aeson.FromJSON SubagentResult + +data SubagentStatus + = SubagentSuccess + | SubagentTimeout + | SubagentCostExceeded + | SubagentError Text + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON SubagentStatus where + toJSON SubagentSuccess = Aeson.String "success" + toJSON SubagentTimeout = Aeson.String "timeout" + toJSON SubagentCostExceeded = Aeson.String "cost_exceeded" + toJSON (SubagentError msg) = Aeson.object ["error" .= msg] + +instance Aeson.FromJSON SubagentStatus where + parseJSON (Aeson.String "success") = pure SubagentSuccess + parseJSON (Aeson.String "timeout") = pure SubagentTimeout + parseJSON (Aeson.String "cost_exceeded") = pure SubagentCostExceeded + parseJSON (Aeson.Object v) = SubagentError </ (v .: "error") + parseJSON _ = empty + +data SubagentCallbacks = SubagentCallbacks + { onSubagentStart :: Text -> IO (), + onSubagentActivity :: Text -> IO (), + onSubagentToolCall :: Text -> Text -> IO (), + onSubagentComplete :: SubagentResult -> IO () + } + +defaultCallbacks :: SubagentCallbacks +defaultCallbacks = + SubagentCallbacks + { onSubagentStart = \_ -> pure (), + onSubagentActivity = \_ -> pure (), + onSubagentToolCall = \_ _ -> pure (), + onSubagentComplete = \_ -> pure () + } + +defaultSubagentConfig :: SubagentRole -> Text -> SubagentConfig +defaultSubagentConfig role task = + SubagentConfig + { subagentRole = role, + subagentTask = task, + subagentModel = Nothing, + subagentTimeout = 600, + subagentMaxCost = 100.0, + subagentMaxTokens = 200000, + subagentMaxIterations = 20, + subagentExtendedThinking = False, + subagentContext = Nothing + } + +modelForRole :: SubagentRole -> Text +modelForRole WebCrawler = "anthropic/claude-3-haiku" +modelForRole CodeReviewer = "anthropic/claude-sonnet-4" +modelForRole DataExtractor = "anthropic/claude-3-haiku" +modelForRole Researcher = "anthropic/claude-sonnet-4" +modelForRole (CustomRole _) = "anthropic/claude-sonnet-4" + +data SubagentApiKeys = SubagentApiKeys + { subagentOpenRouterKey :: Text, + subagentKagiKey :: Maybe Text + } + deriving (Show, Eq) + +toolsForRole :: SubagentRole -> SubagentApiKeys -> [Engine.Tool] +toolsForRole WebCrawler keys = + let webSearchTools = case subagentKagiKey keys of + Just kagiKey -> [WebSearch.webSearchTool kagiKey] + Nothing -> [] + in webSearchTools + <> [ WebReader.webReaderTool (subagentOpenRouterKey keys), + Tools.searchCodebaseTool + ] +toolsForRole CodeReviewer _keys = + [ Tools.readFileTool, + Tools.searchCodebaseTool, + Tools.searchAndReadTool, + Tools.runBashTool + ] +toolsForRole DataExtractor keys = + [ WebReader.webReaderTool (subagentOpenRouterKey keys), + Tools.readFileTool, + Tools.searchCodebaseTool + ] +toolsForRole Researcher keys = + let webSearchTools = case subagentKagiKey keys of + Just kagiKey -> [WebSearch.webSearchTool kagiKey] + Nothing -> [] + in webSearchTools + <> [ WebReader.webReaderTool (subagentOpenRouterKey keys), + Tools.readFileTool, + Tools.searchCodebaseTool, + Tools.searchAndReadTool + ] +toolsForRole (CustomRole _) keys = toolsForRole Researcher keys + +systemPromptForRole :: SubagentRole -> Text -> Maybe Text -> Text +systemPromptForRole role task maybeContext = + Text.unlines + [ "You are a specialized " <> roleDescription role <> " subagent working on a focused task.", + "", + "## Your Task", + task, + "", + maybe "" (\ctx -> "## Context from Orchestrator\n" <> ctx <> "\n") maybeContext, + "## Guidelines", + "1. Be EFFICIENT with context - extract only key facts, don't save full page contents", + "2. Summarize findings as you go rather than accumulating raw data", + "3. Limit web page reads to 3-5 most relevant sources", + "4. Work iteratively: search → skim results → read best 2-3 → synthesize", + "5. ALWAYS cite sources - every claim needs a URL", + "6. Stop when you have sufficient information - don't over-research", + "", + "## Output Format", + "Return findings as a list of structured insights:", + "", + "```json", + "{", + " \"summary\": \"Brief overall summary (1-2 sentences)\",", + " \"confidence\": 0.85,", + " \"findings\": [", + " {", + " \"claim\": \"The key insight or fact discovered\",", + " \"source_url\": \"https://example.com/page\",", + " \"quote\": \"Relevant excerpt supporting the claim\",", + " \"source_name\": \"Example Site\"", + " }", + " ],", + " \"caveats\": \"Any limitations or uncertainties\"", + "}", + "```" + ] + where + roleDescription :: SubagentRole -> Text + roleDescription WebCrawler = "web research" + roleDescription CodeReviewer = "code review" + roleDescription DataExtractor = "data extraction" + roleDescription Researcher = "research" + roleDescription (CustomRole name) = name + +runSubagent :: SubagentApiKeys -> SubagentConfig -> IO SubagentResult +runSubagent keys config = runSubagentWithCallbacks keys config defaultCallbacks + +runSubagentWithCallbacks :: SubagentApiKeys -> SubagentConfig -> SubagentCallbacks -> IO SubagentResult +runSubagentWithCallbacks keys config callbacks = do + startTime <- Clock.getCurrentTime + + let role = subagentRole config + let model = fromMaybe (modelForRole role) (subagentModel config) + let tools = toolsForRole role keys + let systemPrompt = systemPromptForRole role (subagentTask config) (subagentContext config) + + onSubagentStart callbacks ("Starting " <> tshow role <> " subagent...") + + let provider = Provider.defaultOpenRouter (subagentOpenRouterKey keys) model + + let guardrails = + Engine.Guardrails + { Engine.guardrailMaxCostCents = subagentMaxCost config, + Engine.guardrailMaxTokens = subagentMaxTokens config, + Engine.guardrailMaxDuplicateToolCalls = 20, + Engine.guardrailMaxTestFailures = 3, + Engine.guardrailMaxEditFailures = 5 + } + + let agentConfig = + Engine.AgentConfig + { Engine.agentModel = model, + Engine.agentTools = tools, + Engine.agentSystemPrompt = systemPrompt, + Engine.agentMaxIterations = subagentMaxIterations config, + Engine.agentGuardrails = guardrails + } + + let engineConfig = + Engine.EngineConfig + { Engine.engineLLM = Engine.defaultLLM, + Engine.engineOnCost = \_ _ -> pure (), + Engine.engineOnActivity = onSubagentActivity callbacks, + Engine.engineOnToolCall = onSubagentToolCall callbacks, + Engine.engineOnAssistant = \_ -> pure (), + Engine.engineOnToolResult = \_ _ _ -> pure (), + Engine.engineOnComplete = pure (), + Engine.engineOnError = \_ -> pure (), + Engine.engineOnGuardrail = \_ -> pure () + } + + let timeoutMicros = subagentTimeout config * 1000000 + + resultOrTimeout <- + race + (threadDelay timeoutMicros) + (Engine.runAgentWithProvider engineConfig provider agentConfig (subagentTask config)) + + endTime <- Clock.getCurrentTime + let durationSecs = round (Clock.diffUTCTime endTime startTime) + + let result = case resultOrTimeout of + Left () -> + SubagentResult + { subagentOutput = Aeson.object ["error" .= ("Timeout after " <> tshow (subagentTimeout config) <> " seconds" :: Text)], + subagentSummary = "Subagent timed out", + subagentConfidence = 0.0, + subagentTokensUsed = 0, + subagentCostCents = 0.0, + subagentDuration = durationSecs, + subagentIterations = 0, + subagentStatus = SubagentTimeout + } + Right (Left err) -> + let status = if "cost" `Text.isInfixOf` Text.toLower err then SubagentCostExceeded else SubagentError err + in SubagentResult + { subagentOutput = Aeson.object ["error" .= err], + subagentSummary = "Subagent failed: " <> err, + subagentConfidence = 0.0, + subagentTokensUsed = 0, + subagentCostCents = 0.0, + subagentDuration = durationSecs, + subagentIterations = 0, + subagentStatus = status + } + Right (Right agentResult) -> + SubagentResult + { subagentOutput = Aeson.object ["response" .= Engine.resultFinalMessage agentResult], + subagentSummary = truncateSummary (Engine.resultFinalMessage agentResult), + subagentConfidence = 0.8, + subagentTokensUsed = Engine.resultTotalTokens agentResult, + subagentCostCents = Engine.resultTotalCost agentResult, + subagentDuration = durationSecs, + subagentIterations = Engine.resultIterations agentResult, + subagentStatus = SubagentSuccess + } + + onSubagentComplete callbacks result + pure result + where + truncateSummary :: Text -> Text + truncateSummary txt = + let firstLine = Text.takeWhile (/= '\n') txt + in if Text.length firstLine > 200 + then Text.take 197 firstLine <> "..." + else firstLine + +spawnSubagentTool :: SubagentApiKeys -> Engine.Tool +spawnSubagentTool keys = + Engine.Tool + { Engine.toolName = "spawn_subagent", + Engine.toolDescription = + "Spawn a specialized subagent for a focused task. " + <> "Use for tasks that benefit from deep exploration, parallel execution, " + <> "or specialized tools. The subagent will iterate until task completion " + <> "or resource limits are reached. " + <> "Available roles: web_crawler (fast web research), code_reviewer (thorough code analysis), " + <> "data_extractor (structured data extraction), researcher (general research).", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "role" + .= Aeson.object + [ "type" .= ("string" :: Text), + "enum" .= (["web_crawler", "code_reviewer", "data_extractor", "researcher"] :: [Text]), + "description" .= ("Subagent role determining tools and model" :: Text) + ], + "task" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("The specific task for the subagent to accomplish" :: Text) + ], + "context" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Additional context to help the subagent understand the goal" :: Text) + ], + "model" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Override the default model for this role" :: Text) + ], + "timeout" + .= Aeson.object + [ "type" .= ("integer" :: Text), + "description" .= ("Timeout in seconds (default: 600)" :: Text) + ], + "max_cost_cents" + .= Aeson.object + [ "type" .= ("number" :: Text), + "description" .= ("Maximum cost in cents (default: 50)" :: Text) + ] + ], + "required" .= (["role", "task"] :: [Text]) + ], + Engine.toolExecute = executeSpawnSubagent keys + } + +executeSpawnSubagent :: SubagentApiKeys -> Aeson.Value -> IO Aeson.Value +executeSpawnSubagent keys v = + case Aeson.fromJSON v of + Aeson.Error e -> pure <| Aeson.object ["error" .= ("Invalid arguments: " <> Text.pack e)] + Aeson.Success config -> do + result <- runSubagent keys config + pure (Aeson.toJSON result) diff --git a/Omni/Agent/Subagent/DESIGN.md b/Omni/Agent/Subagent/DESIGN.md new file mode 100644 index 0000000..9fd20d1 --- /dev/null +++ b/Omni/Agent/Subagent/DESIGN.md @@ -0,0 +1,352 @@ +# Subagent System Design + +**Status:** Draft +**Goal:** Enable Ava (orchestrator) to spawn specialized subagents for parallel, token-intensive tasks. + +## 1. Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Ava (Orchestrator) │ +│ Model: claude-sonnet-4.5 (via OpenRouter) │ +│ Role: Task decomposition, delegation, synthesis │ +│ Memory: Read/Write access │ +├─────────────────────────────────────────────────────────────┤ +│ Tools: spawn_subagent, all existing Ava tools │ +└───────────────┬───────────────────────────────────────┬─────┘ + │ │ + ▼ ▼ +┌───────────────────────────┐ ┌───────────────────────────┐ +│ Subagent: WebCrawler │ │ Subagent: CodeReviewer │ +│ Model: claude-haiku │ │ Model: claude-opus │ +│ Tools: web_search, │ │ Tools: read_file, │ +│ http_get, │ │ search_codebase, │ +│ python_exec │ │ run_bash │ +│ Memory: Read-only │ │ Memory: Read-only │ +│ Limits: 600s, $0.50 │ │ Limits: 300s, $1.00 │ +└───────────────────────────┘ └───────────────────────────┘ +``` + +## 2. Key Design Decisions + +### 2.1 Hierarchical (No Sub-Subagents) +- Subagents cannot spawn their own subagents +- Prevents runaway token consumption +- Keeps orchestrator in control + +### 2.2 Memory Access +- **Orchestrator (Ava):** Full read/write to Memory system +- **Subagents:** Read-only access to memories +- Prevents conflicting memory writes from parallel agents + +### 2.3 Model Selection by Role +| Role | Model | Rationale | +|------|-------|-----------| +| Orchestrator | claude-sonnet-4.5 | Balance of capability/cost | +| Deep reasoning | claude-opus | Complex analysis, architecture | +| Quick tasks | claude-haiku | Fast, cheap for simple lookups | +| Code tasks | claude-sonnet | Good code understanding | + +### 2.4 Resource Limits (Guardrails) +Each subagent has strict limits: +- **Timeout:** Max wall-clock time (default: 600s) +- **Cost cap:** Max spend in cents (default: 50c) +- **Token cap:** Max total tokens (default: 100k) +- **Iteration cap:** Max agent loop iterations (default: 20) + +### 2.5 Extended Thinking +- Configurable per-subagent +- Enabled for deep research tasks +- Disabled for quick lookups + +## 3. Data Types + +```haskell +-- | Subagent role determines toolset and model +data SubagentRole + = WebCrawler -- Deep web research + | CodeReviewer -- Code analysis, PR review + | DataExtractor -- Structured data extraction + | Researcher -- General research with web+docs + | CustomRole Text -- User-defined role + deriving (Show, Eq, Generic) + +-- | Configuration for spawning a subagent +data SubagentConfig = SubagentConfig + { subagentRole :: SubagentRole + , subagentTask :: Text -- What to accomplish + , subagentModel :: Maybe Text -- Override default model + , subagentTimeout :: Int -- Seconds (default: 600) + , subagentMaxCost :: Double -- Cents (default: 50.0) + , subagentMaxTokens :: Int -- Default: 100000 + , subagentMaxIterations :: Int -- Default: 20 + , subagentExtendedThinking :: Bool + , subagentContext :: Maybe Text -- Additional context from orchestrator + } deriving (Show, Eq, Generic) + +-- | Result returned by subagent to orchestrator +data SubagentResult = SubagentResult + { subagentOutput :: Aeson.Value -- Structured result + , subagentSummary :: Text -- Human-readable summary + , subagentConfidence :: Double -- 0.0-1.0 confidence score + , subagentTokensUsed :: Int + , subagentCostCents :: Double + , subagentDuration :: Int -- Seconds + , subagentIterations :: Int + , subagentStatus :: SubagentStatus + } deriving (Show, Eq, Generic) + +data SubagentStatus + = SubagentSuccess + | SubagentTimeout + | SubagentCostExceeded + | SubagentError Text + deriving (Show, Eq, Generic) +``` + +## 4. Tool: spawn_subagent + +This is the main interface for the orchestrator to spawn subagents. + +```haskell +spawnSubagentTool :: Engine.Tool +spawnSubagentTool = Engine.Tool + { toolName = "spawn_subagent" + , toolDescription = + "Spawn a specialized subagent for a focused task. " + <> "Use for tasks that benefit from deep exploration, parallel execution, " + <> "or specialized tools. The subagent will iterate until task completion " + <> "or resource limits are reached." + , toolJsonSchema = ... + , toolExecute = executeSpawnSubagent + } +``` + +**Parameters:** +```json +{ + "role": "web_crawler | code_reviewer | data_extractor | researcher | custom", + "task": "Research competitor pricing for podcast transcription services", + "context": "We're building a pricing page and need market data", + "model": "claude-haiku", + "timeout": 600, + "max_cost_cents": 50, + "extended_thinking": false +} +``` + +**Response:** +```json +{ + "status": "success", + "summary": "Found 5 competitors with pricing ranging from $0.10-$0.25/min", + "output": { + "competitors": [ + {"name": "Otter.ai", "pricing": "$0.12/min", "features": ["..."]}, + ... + ] + }, + "confidence": 0.85, + "tokens_used": 45000, + "cost_cents": 23.5, + "duration_seconds": 180, + "iterations": 8 +} +``` + +## 5. Role-Specific Tool Sets + +### 5.1 WebCrawler +```haskell +webCrawlerTools :: [Engine.Tool] +webCrawlerTools = + [ webSearchTool -- Search the web + , webReaderTool -- Fetch and parse web pages + , pythonExecTool -- Execute Python for data processing + ] +``` +**Use case:** Deep market research, competitive analysis, documentation gathering + +### 5.2 CodeReviewer +```haskell +codeReviewerTools :: [Engine.Tool] +codeReviewerTools = + [ readFileTool + , searchCodebaseTool + , searchAndReadTool + , runBashTool -- For running tests, linters + ] +``` +**Use case:** PR review, architecture analysis, test verification + +### 5.3 DataExtractor +```haskell +dataExtractorTools :: [Engine.Tool] +dataExtractorTools = + [ webReaderTool + , pythonExecTool + ] +``` +**Use case:** Scraping structured data, parsing PDFs, extracting metrics + +### 5.4 Researcher +```haskell +researcherTools :: [Engine.Tool] +researcherTools = + [ webSearchTool + , webReaderTool + , readFileTool + , searchCodebaseTool + ] +``` +**Use case:** General research combining web and local codebase + +## 6. Subagent System Prompt Template + +``` +You are a specialized {ROLE} subagent working on a focused task. + +## Your Task +{TASK} + +## Context from Orchestrator +{CONTEXT} + +## Your Capabilities +{TOOL_DESCRIPTIONS} + +## Guidelines +1. Work iteratively: search → evaluate → refine → verify +2. Return structured data when possible (JSON objects) +3. Include confidence scores for your findings +4. If stuck, explain what you tried and what didn't work +5. Stop when you have sufficient information OR hit resource limits + +## Output Format +When complete, provide: +1. A structured result (JSON) with the requested data +2. A brief summary of findings +3. Confidence score (0.0-1.0) indicating reliability +4. Any caveats or limitations +``` + +## 7. Orchestrator Delegation Logic + +The orchestrator (Ava) should spawn subagents when: + +1. **Deep research needed:** "Research all competitors in X market" +2. **Parallel tasks:** Multiple independent subtasks that can run concurrently +3. **Specialized tools:** Task requires tools the orchestrator shouldn't use directly +4. **Token-intensive:** Task would consume excessive tokens in main context + +The orchestrator should NOT spawn subagents for: + +1. **Simple queries:** Quick lookups, single tool calls +2. **Conversation continuation:** Multi-turn dialogue with user +3. **Memory writes:** Tasks that need to update Ava's memory + +## 8. Execution Flow + +``` +1. Orchestrator calls spawn_subagent tool +2. Subagent module: + a. Creates fresh agent config from SubagentConfig + b. Selects model based on role (or override) + c. Builds tool list for role + d. Constructs system prompt + e. Calls Engine.runAgentWithProvider + f. Monitors resource usage + g. Returns SubagentResult +3. Orchestrator receives structured result +4. Orchestrator synthesizes into response +``` + +## 9. Concurrency Model + +Initial implementation: **Sequential** (one subagent at a time) + +Future enhancement: **Parallel** spawning with: +- `async` library for concurrent execution +- Aggregate cost tracking across all subagents +- Combined timeout for parallel group + +```haskell +-- Future: Parallel spawning +spawnParallel :: [SubagentConfig] -> IO [SubagentResult] +spawnParallel configs = mapConcurrently runSubagent configs +``` + +## 10. Status Reporting + +Subagents report status back to the orchestrator via callbacks: + +```haskell +data SubagentCallbacks = SubagentCallbacks + { onSubagentStart :: Text -> IO () -- "Starting web research..." + , onSubagentActivity :: Text -> IO () -- "Searching for X..." + , onSubagentToolCall :: Text -> Text -> IO () -- Tool name, args + , onSubagentComplete :: SubagentResult -> IO () + } +``` + +For Telegram, this appears as: +``` +🔍 Subagent [WebCrawler]: Starting research... +🔍 Subagent [WebCrawler]: Searching "podcast transcription pricing"... +🔍 Subagent [WebCrawler]: Reading otter.ai/pricing... +✅ Subagent [WebCrawler]: Complete (180s, $0.24) +``` + +## 11. Implementation Plan + +### Phase 1: Core Infrastructure +1. Create `Omni/Agent/Subagent.hs` with data types +2. Implement `runSubagent` function using existing Engine +3. Add `spawn_subagent` tool +4. Basic WebCrawler role with existing web tools + +### Phase 2: Role Expansion +1. Add CodeReviewer role +2. Add DataExtractor role +3. Add Researcher role +4. Custom role support + +### Phase 3: Advanced Features +1. Parallel subagent execution +2. Extended thinking integration +3. Cross-subagent context sharing +4. Cost aggregation and budgeting + +## 12. Testing Strategy + +```haskell +test :: Test.Tree +test = Test.group "Omni.Agent.Subagent" + [ Test.unit "SubagentConfig JSON roundtrip" <| ... + , Test.unit "role selects correct tools" <| ... + , Test.unit "timeout terminates subagent" <| ... + , Test.unit "cost limit stops execution" <| ... + , Test.unit "WebCrawler role has web tools" <| ... + ] +``` + +## 13. Cost Analysis + +Based on Anthropic's research findings: +- Subagents use ~15× more tokens than single-agent +- But provide better results for complex tasks +- 80% of performance variance from token budget + +**Budget recommendations:** +| Task Type | Subagent Budget | Expected Tokens | +|-----------|-----------------|-----------------| +| Quick lookup | $0.10 | ~10k | +| Standard research | $0.50 | ~50k | +| Deep analysis | $2.00 | ~200k | + +## 14. References + +- [Claude Agent SDK - Subagents](https://platform.claude.com/docs/en/agent-sdk/subagents) +- [Multi-Agent Research System](https://www.anthropic.com/engineering/multi-agent-research-system) +- [OpenAI Agents Python](https://openai.github.io/openai-agents-python/agents/) +- Existing: `Omni/Agent/Engine.hs`, `Omni/Agent/Provider.hs`, `Omni/Agent/Tools.hs` diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs index e964688..e94e73d 100644 --- a/Omni/Agent/Telegram.hs +++ b/Omni/Agent/Telegram.hs @@ -83,6 +83,7 @@ import qualified Omni.Agent.Engine as Engine import qualified Omni.Agent.Memory as Memory import qualified Omni.Agent.Provider as Provider import qualified Omni.Agent.Skills as Skills +import qualified Omni.Agent.Subagent as Subagent import qualified Omni.Agent.Telegram.IncomingQueue as IncomingQueue import qualified Omni.Agent.Telegram.Media as Media import qualified Omni.Agent.Telegram.Messages as Messages @@ -1012,7 +1013,17 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe Skills.listSkillsTool userName, Skills.publishSkillTool userName ] - tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> feedbackTools <> fileTools <> skillsTools + subagentTools = + if isBenAuthorized userName + then + let keys = + Subagent.SubagentApiKeys + { Subagent.subagentOpenRouterKey = Types.tgOpenRouterApiKey tgConfig, + Subagent.subagentKagiKey = Types.tgKagiApiKey tgConfig + } + in [Subagent.spawnSubagentTool keys] + else [] + tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> feedbackTools <> fileTools <> skillsTools <> subagentTools let agentCfg = Engine.defaultAgentConfig |
