diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-01 22:28:30 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-01 22:28:30 -0500 |
| commit | 0c3b77c06028205aac0184973037355689fc3c9e (patch) | |
| tree | 8a32c54c831b7feeeac299791658e931bbd2167b /Omni | |
| parent | f1b81c53f243fbb3036fee9531294de6d7df5763 (diff) | |
Compact amp-style timeline rendering and targeted file reading
Timeline tool display:
- Grep/search: ✓ Grep pattern in filepath
- Read file: ✓ Read filepath @start-end
- Edit file: ✓ Edit filepath
- Bash: ϟ command (lightning bolt prompt)
- Tool results only shown for meaningful output
New search_and_read tool:
- Combines search + read in one operation
- Uses ripgrep --context for surrounding lines
- More efficient than separate search then read
Worker prompt updated to prefer search_and_read over
separate search + read_file calls
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Agent/Tools.hs | 84 | ||||
| -rw-r--r-- | Omni/Agent/Worker.hs | 10 | ||||
| -rw-r--r-- | Omni/Jr/Web/Components.hs | 178 | ||||
| -rw-r--r-- | Omni/Jr/Web/Style.hs | 64 |
4 files changed, 308 insertions, 28 deletions
diff --git a/Omni/Agent/Tools.hs b/Omni/Agent/Tools.hs index 9664c76..22cc8a1 100644 --- a/Omni/Agent/Tools.hs +++ b/Omni/Agent/Tools.hs @@ -22,12 +22,14 @@ module Omni.Agent.Tools editFileTool, runBashTool, searchCodebaseTool, + searchAndReadTool, allTools, ReadFileArgs (..), WriteFileArgs (..), EditFileArgs (..), RunBashArgs (..), SearchCodebaseArgs (..), + SearchAndReadArgs (..), ToolResult (..), main, test, @@ -78,8 +80,8 @@ test = case schema of Aeson.Object _ -> pure () _ -> Test.assertFailure "Schema should be an object", - Test.unit "allTools contains 5 tools" <| do - length allTools Test.@=? 5, + Test.unit "allTools contains 6 tools" <| do + length allTools Test.@=? 6, Test.unit "ReadFileArgs parses correctly" <| do let json = Aeson.object ["path" .= ("test.txt" :: Text)] case Aeson.fromJSON json of @@ -220,7 +222,8 @@ allTools = writeFileTool, editFileTool, runBashTool, - searchCodebaseTool + searchCodebaseTool, + searchAndReadTool ] data ReadFileArgs = ReadFileArgs @@ -602,3 +605,78 @@ executeSearchCodebase v = pure <| mkSuccess "No matches found" Exit.ExitFailure code -> pure <| mkError ("ripgrep failed with code " <> tshow code <> ": " <> Text.pack stderrStr) + +data SearchAndReadArgs = SearchAndReadArgs + { sarPattern :: Text, + sarPath :: Maybe Text, + sarContextLines :: Maybe Int + } + deriving (Show, Eq, Generic) + +instance Aeson.FromJSON SearchAndReadArgs where + parseJSON = + Aeson.withObject "SearchAndReadArgs" <| \v -> + (SearchAndReadArgs </ (v .: "pattern")) + <*> (v .:? "path") + <*> (v .:? "context_lines") + +searchAndReadTool :: Engine.Tool +searchAndReadTool = + Engine.Tool + { Engine.toolName = "search_and_read", + Engine.toolDescription = + "Search for a pattern, then read the matching lines with context. " + <> "More efficient than search + read separately - returns file content " + <> "around each match. Use this to find and understand code in one step.", + Engine.toolJsonSchema = + Aeson.object + [ "type" .= ("object" :: Text), + "properties" + .= Aeson.object + [ "pattern" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Regex pattern to search for" :: Text) + ], + "path" + .= Aeson.object + [ "type" .= ("string" :: Text), + "description" .= ("Optional: directory or file to search in" :: Text) + ], + "context_lines" + .= Aeson.object + [ "type" .= ("integer" :: Text), + "description" .= ("Lines of context around each match (default: 10)" :: Text) + ] + ], + "required" .= (["pattern"] :: [Text]) + ], + Engine.toolExecute = executeSearchAndRead + } + +executeSearchAndRead :: Aeson.Value -> IO Aeson.Value +executeSearchAndRead v = + case Aeson.fromJSON v of + Aeson.Error e -> pure <| mkError (Text.pack e) + Aeson.Success args -> do + let pat = Text.unpack (sarPattern args) + ctx = fromMaybe 10 (sarContextLines args) + pathArg = maybe ["."] (\p -> [Text.unpack p]) (sarPath args) + rgArgs = + [ "--line-number", + "--no-heading", + "--context=" <> show ctx, + "--max-count=20", + "--ignore-case", + pat + ] + <> pathArg + proc = Process.proc "rg" rgArgs + (exitCode, stdoutStr, stderrStr) <- Process.readCreateProcessWithExitCode proc "" + case exitCode of + Exit.ExitSuccess -> + pure <| mkSuccess (Text.pack stdoutStr) + Exit.ExitFailure 1 -> + pure <| mkSuccess "No matches found" + Exit.ExitFailure code -> + pure <| mkError ("ripgrep failed: " <> tshow code <> ": " <> Text.pack stderrStr) diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs index 07293c5..45caf9b 100644 --- a/Omni/Agent/Worker.hs +++ b/Omni/Agent/Worker.hs @@ -399,12 +399,14 @@ buildBasePrompt task ns repo = <> "- Do not re-run passing tests\n" <> "- Do not test files individually when namespace test covers them\n" <> "- Aim to complete the task in under 50 tool calls\n\n" - <> "LARGE FILE HANDLING:\n" - <> "- When reading large files (>500 lines), use line ranges to read only relevant sections\n" + <> "EFFICIENT FILE READING:\n" + <> "- PREFER search_and_read over separate search + read_file calls\n" + <> "- search_and_read finds code AND returns context around matches in one call\n" + <> "- Only use read_file with line ranges (start_line/end_line) for targeted reads\n" + <> "- NEVER read entire large files - always search first, then read specific sections\n" <> "- For edit_file, use minimal unique context - just enough lines to match uniquely\n" <> "- If edit_file fails with 'old_str not found', re-read the exact lines you need to edit\n" - <> "- After 2-3 failed edits on the same file, STOP and reconsider your approach\n" - <> "- Very large files (>2000 lines) may need refactoring - note this for human review\n\n" + <> "- After 2-3 failed edits on the same file, STOP and reconsider your approach\n\n" <> "Context:\n" <> "- Working directory: " <> Text.pack repo diff --git a/Omni/Jr/Web/Components.hs b/Omni/Jr/Web/Components.hs index 3a9df0f..2f885ce 100644 --- a/Omni/Jr/Web/Components.hs +++ b/Omni/Jr/Web/Components.hs @@ -120,11 +120,26 @@ module Omni.Jr.Web.Components renderDecodedToolResult, renderFormattedJson, timelineScrollScript, + + -- * Tool rendering helpers + renderBashToolCall, + renderReadToolCall, + renderEditToolCall, + renderSearchToolCall, + renderSearchAndReadToolCall, + renderWriteToolCall, + renderGenericToolCall, + extractJsonField, + extractJsonFieldInt, + shortenPath, + DecodedToolResult (..), + decodeToolResult, ) where import Alpha import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Key as AesonKey import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.ByteString.Lazy as LBS import qualified Data.List as List @@ -1479,29 +1494,150 @@ renderAssistantTimelineEvent content _actor timestamp now = when isTruncated <| Lucid.span_ [Lucid.class_ "event-truncated"] "..." renderToolCallTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m () -renderToolCallTimelineEvent content _actor timestamp now = - let (toolName, args) = parseToolCallContent content - summary = formatToolCallSummary toolName args - in Lucid.details_ [Lucid.class_ "timeline-tool-call"] <| do - Lucid.summary_ <| do - Lucid.span_ [Lucid.class_ "event-icon"] "🔧" - Lucid.span_ [Lucid.class_ "tool-name"] (Lucid.toHtml toolName) - Lucid.span_ [Lucid.class_ "tool-summary"] (Lucid.toHtml summary) - renderRelativeTimestamp now timestamp - Lucid.div_ [Lucid.class_ "event-content tool-args"] <| do - renderCollapsibleOutput args +renderToolCallTimelineEvent content _actor _timestamp _now = + let (toolName, argsJson) = parseToolCallContent content + in case toolName of + "run_bash" -> renderBashToolCall argsJson + "read_file" -> renderReadToolCall argsJson + "edit_file" -> renderEditToolCall argsJson + "search_codebase" -> renderSearchToolCall argsJson + "search_and_read" -> renderSearchAndReadToolCall argsJson + "write_file" -> renderWriteToolCall argsJson + _ -> renderGenericToolCall toolName argsJson + +renderBashToolCall :: (Monad m) => Text -> Lucid.HtmlT m () +renderBashToolCall argsJson = + let cmd = extractJsonField "command" argsJson + in Lucid.div_ [Lucid.class_ "tool-bash"] <| do + Lucid.span_ [Lucid.class_ "tool-bash-prompt"] "ϟ" + Lucid.code_ [Lucid.class_ "tool-bash-cmd"] (Lucid.toHtml cmd) + +renderReadToolCall :: (Monad m) => Text -> Lucid.HtmlT m () +renderReadToolCall argsJson = + let path = extractJsonField "path" argsJson + startLine = extractJsonFieldInt "start_line" argsJson + endLine = extractJsonFieldInt "end_line" argsJson + lineRange = case (startLine, endLine) of + (Just s, Just e) -> " @" <> tshow s <> "-" <> tshow e + (Just s, Nothing) -> " @" <> tshow s <> "+" + _ -> "" + in Lucid.div_ [Lucid.class_ "tool-compact"] <| do + Lucid.span_ [Lucid.class_ "tool-check"] "✓" + Lucid.span_ [Lucid.class_ "tool-label"] "Read" + Lucid.code_ [Lucid.class_ "tool-path"] (Lucid.toHtml (shortenPath path <> lineRange)) + +renderEditToolCall :: (Monad m) => Text -> Lucid.HtmlT m () +renderEditToolCall argsJson = + let path = extractJsonField "path" argsJson + in Lucid.div_ [Lucid.class_ "tool-compact"] <| do + Lucid.span_ [Lucid.class_ "tool-check"] "✓" + Lucid.span_ [Lucid.class_ "tool-label"] "Edit" + Lucid.code_ [Lucid.class_ "tool-path"] (Lucid.toHtml (shortenPath path)) + +renderSearchToolCall :: (Monad m) => Text -> Lucid.HtmlT m () +renderSearchToolCall argsJson = + let searchPat = extractJsonField "pattern" argsJson + searchPath = extractJsonField "path" argsJson + pathSuffix = if Text.null searchPath || searchPath == "." then "" else " in " <> shortenPath searchPath + in Lucid.div_ [Lucid.class_ "tool-compact"] <| do + Lucid.span_ [Lucid.class_ "tool-check"] "✓" + Lucid.span_ [Lucid.class_ "tool-label"] "Grep" + Lucid.code_ [Lucid.class_ "tool-pattern"] (Lucid.toHtml searchPat) + unless (Text.null pathSuffix) + <| Lucid.span_ [Lucid.class_ "tool-path-suffix"] (Lucid.toHtml pathSuffix) + +renderWriteToolCall :: (Monad m) => Text -> Lucid.HtmlT m () +renderWriteToolCall argsJson = + let path = extractJsonField "path" argsJson + in Lucid.div_ [Lucid.class_ "tool-compact"] <| do + Lucid.span_ [Lucid.class_ "tool-check"] "✓" + Lucid.span_ [Lucid.class_ "tool-label"] "Write" + Lucid.code_ [Lucid.class_ "tool-path"] (Lucid.toHtml (shortenPath path)) + +renderSearchAndReadToolCall :: (Monad m) => Text -> Lucid.HtmlT m () +renderSearchAndReadToolCall argsJson = + let searchPat = extractJsonField "pattern" argsJson + searchPath = extractJsonField "path" argsJson + pathSuffix = if Text.null searchPath || searchPath == "." then "" else " in " <> shortenPath searchPath + in Lucid.div_ [Lucid.class_ "tool-compact"] <| do + Lucid.span_ [Lucid.class_ "tool-check"] "✓" + Lucid.span_ [Lucid.class_ "tool-label"] "Find" + Lucid.code_ [Lucid.class_ "tool-pattern"] (Lucid.toHtml searchPat) + unless (Text.null pathSuffix) + <| Lucid.span_ [Lucid.class_ "tool-path-suffix"] (Lucid.toHtml pathSuffix) + +renderGenericToolCall :: (Monad m) => Text -> Text -> Lucid.HtmlT m () +renderGenericToolCall toolName argsJson = + Lucid.details_ [Lucid.class_ "tool-generic"] <| do + Lucid.summary_ <| do + Lucid.span_ [Lucid.class_ "tool-check"] "✓" + Lucid.span_ [Lucid.class_ "tool-label"] (Lucid.toHtml toolName) + Lucid.pre_ [Lucid.class_ "tool-args-pre"] (Lucid.toHtml argsJson) + +extractJsonField :: Text -> Text -> Text +extractJsonField field jsonText = + case Aeson.decode (LBS.fromStrict (str jsonText)) of + Just (Aeson.Object obj) -> + case KeyMap.lookup (AesonKey.fromText field) obj of + Just (Aeson.String s) -> s + _ -> "" + _ -> "" + +extractJsonFieldInt :: Text -> Text -> Maybe Int +extractJsonFieldInt field jsonText = + case Aeson.decode (LBS.fromStrict (str jsonText)) of + Just (Aeson.Object obj) -> + case KeyMap.lookup (AesonKey.fromText field) obj of + Just (Aeson.Number n) -> Just (floor n) + _ -> Nothing + _ -> Nothing + +shortenPath :: Text -> Text +shortenPath path = + let parts = Text.splitOn "/" path + relevant = dropWhile (\p -> p `elem` ["", "home", "ben", "omni"]) parts + in Text.intercalate "/" relevant renderToolResultTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m () -renderToolResultTimelineEvent content _actor timestamp now = - let lineCount = length (Text.lines content) - in Lucid.details_ [Lucid.class_ "timeline-tool-result"] <| do - Lucid.summary_ <| do - Lucid.span_ [Lucid.class_ "event-icon"] "📄" - Lucid.span_ [Lucid.class_ "event-label"] "Result" - when (lineCount > 1) - <| Lucid.span_ [Lucid.class_ "line-count"] (Lucid.toHtml (tshow lineCount <> " lines")) - renderRelativeTimestamp now timestamp - Lucid.pre_ [Lucid.class_ "event-content tool-output"] (renderDecodedToolResult content) +renderToolResultTimelineEvent content _actor _timestamp _now = + let decoded = decodeToolResult content + isSuccess = toolResultIsSuccess decoded + output = toolResultOutput' decoded + lineCount = length (Text.lines output) + in if Text.null output || (isSuccess && lineCount <= 1) + then pure () + else + Lucid.div_ [Lucid.class_ "tool-result-output"] <| do + when (lineCount > 10) + <| Lucid.details_ [Lucid.class_ "result-collapsible"] + <| do + Lucid.summary_ (Lucid.toHtml (tshow lineCount <> " lines")) + Lucid.pre_ [Lucid.class_ "tool-output-pre"] (Lucid.toHtml output) + when (lineCount <= 10) + <| Lucid.pre_ [Lucid.class_ "tool-output-pre"] (Lucid.toHtml output) + +data DecodedToolResult = DecodedToolResult + { toolResultIsSuccess :: Bool, + toolResultOutput' :: Text, + toolResultError' :: Maybe Text + } + +decodeToolResult :: Text -> DecodedToolResult +decodeToolResult content = + case Aeson.decode (LBS.fromStrict (str content)) of + Just (Aeson.Object obj) -> + DecodedToolResult + { toolResultIsSuccess = case KeyMap.lookup "success" obj of + Just (Aeson.Bool b) -> b + _ -> True, + toolResultOutput' = case KeyMap.lookup "output" obj of + Just (Aeson.String s) -> s + _ -> "", + toolResultError' = case KeyMap.lookup "error" obj of + Just (Aeson.String s) -> Just s + _ -> Nothing + } + _ -> DecodedToolResult True content Nothing renderCheckpointEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m () renderCheckpointEvent content actor timestamp now = diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs index 0f4b300..ee5136e 100644 --- a/Omni/Jr/Web/Style.hs +++ b/Omni/Jr/Web/Style.hs @@ -1815,6 +1815,70 @@ unifiedTimelineStyles = do fontFamily ["SF Mono", "Monaco", "Consolas", "monospace"] [monospace] whiteSpace preWrap overflowWrap breakWord + compactToolStyles + +compactToolStyles :: Css +compactToolStyles = do + ".tool-compact" ? do + display flex + alignItems center + Stylesheet.key "gap" ("6px" :: Text) + fontFamily ["SF Mono", "Monaco", "Consolas", "monospace"] [monospace] + fontSize (px 12) + padding (px 2) (px 0) (px 2) (px 0) + ".tool-check" ? do + color "#10b981" + fontWeight bold + ".tool-label" ? do + color "#6b7280" + fontWeight (weight 500) + ".tool-path" ? do + color "#3b82f6" + ".tool-pattern" ? do + color "#8b5cf6" + backgroundColor "#f5f3ff" + padding (px 1) (px 4) (px 1) (px 4) + borderRadius (px 2) (px 2) (px 2) (px 2) + ".tool-path-suffix" ? do + color "#6b7280" + fontSize (px 11) + ".tool-bash" ? do + display flex + alignItems flexStart + Stylesheet.key "gap" ("6px" :: Text) + fontFamily ["SF Mono", "Monaco", "Consolas", "monospace"] [monospace] + fontSize (px 12) + padding (px 2) (px 0) (px 2) (px 0) + ".tool-bash-prompt" ? do + color "#f59e0b" + fontWeight bold + fontSize (px 14) + ".tool-bash-cmd" ? do + color "#374151" + backgroundColor "#f3f4f6" + padding (px 2) (px 6) (px 2) (px 6) + borderRadius (px 3) (px 3) (px 3) (px 3) + wordBreak breakAll + ".tool-generic" ? do + fontSize (px 12) + fontFamily ["SF Mono", "Monaco", "Consolas", "monospace"] [monospace] + ".tool-generic" |> "summary" ? do + cursor pointer + display flex + alignItems center + Stylesheet.key "gap" ("6px" :: Text) + ".tool-args-pre" ? do + margin (px 4) (px 0) (px 0) (px 16) + padding (px 6) (px 8) (px 6) (px 8) + backgroundColor "#f9fafb" + borderRadius (px 3) (px 3) (px 3) (px 3) + fontSize (px 11) + whiteSpace preWrap + maxHeight (px 200) + overflowY auto + ".tool-result-output" ? do + marginLeft (px 16) + marginTop (px 2) responsiveStyles :: Css responsiveStyles = do |
