summaryrefslogtreecommitdiff
path: root/Omni/Agent/Subagent.hs
diff options
context:
space:
mode:
Diffstat (limited to 'Omni/Agent/Subagent.hs')
-rw-r--r--Omni/Agent/Subagent.hs516
1 files changed, 516 insertions, 0 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)