summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Omni/Agent/Subagent.hs516
-rw-r--r--Omni/Agent/Subagent/DESIGN.md352
-rw-r--r--Omni/Agent/Telegram.hs13
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