diff options
Diffstat (limited to 'Omni/Agent')
| -rw-r--r-- | Omni/Agent/Git.hs | 29 | ||||
| -rw-r--r-- | Omni/Agent/Log.hs | 155 | ||||
| -rw-r--r-- | Omni/Agent/LogTest.hs | 78 | ||||
| -rw-r--r-- | Omni/Agent/Worker.hs | 81 |
4 files changed, 219 insertions, 124 deletions
diff --git a/Omni/Agent/Git.hs b/Omni/Agent/Git.hs index a2009b2..b1978f2 100644 --- a/Omni/Agent/Git.hs +++ b/Omni/Agent/Git.hs @@ -25,7 +25,6 @@ import Omni.Test ((@=?)) import qualified Omni.Test as Test import qualified System.Directory as Directory import qualified System.Exit as Exit -import System.FilePath ((</>)) import qualified System.IO.Temp as Temp import qualified System.Process as Process @@ -149,30 +148,16 @@ syncWithLive repo = do Log.info ["git", "syncing with live"] -- git repo ["fetch", "origin", "live"] -- Optional - -- Try rebase, if fail, abort - -- First, proactively cleanup any stale rebase state - cleanupStaleRebase repo - - let cmd = (Process.proc "git" ["rebase", "live"]) {Process.cwd = Just repo} - (code, _, err) <- Process.readCreateProcessWithExitCode cmd "" + -- Try sync (branchless sync), if fail, panic + -- This replaces manual rebase and handles stack movement + let cmd = (Process.proc "git" ["sync"]) {Process.cwd = Just repo} + (code, out, err) <- Process.readCreateProcessWithExitCode cmd "" case code of Exit.ExitSuccess -> pure () Exit.ExitFailure _ -> do - Log.warn ["rebase failed, aborting", Text.pack err] - cleanupStaleRebase repo - panic "Sync with live failed (rebase conflict)" - -cleanupStaleRebase :: FilePath -> IO () -cleanupStaleRebase repo = do - -- Check if a rebase is in progress - rebaseMerge <- Directory.doesDirectoryExist (repo </> ".git/rebase-merge") - rebaseApply <- Directory.doesDirectoryExist (repo </> ".git/rebase-apply") - - when (rebaseMerge || rebaseApply) <| do - Log.warn ["git", "detected stale rebase", "aborting"] - let abort = (Process.proc "git" ["rebase", "--abort"]) {Process.cwd = Just repo} - _ <- Process.readCreateProcessWithExitCode abort "" - pure () + Log.warn ["git sync failed", Text.pack err] + Log.info [Text.pack out] + panic "Sync with live failed (git sync)" commit :: FilePath -> Text -> IO () commit repo msg = do diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs index 93c70e1..0491568 100644 --- a/Omni/Agent/Log.hs +++ b/Omni/Agent/Log.hs @@ -6,17 +6,34 @@ module Omni.Agent.Log where import Alpha +import Data.Aeson (Value (..), decode) +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString.Lazy as BL import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) +import qualified Data.Text.Encoding as TextEnc import qualified Data.Text.IO as TIO +import qualified Data.Vector as V import qualified System.Console.ANSI as ANSI import qualified System.IO as IO import System.IO.Unsafe (unsafePerformIO) import Text.Printf (printf) +-- | Parsed log entry +data LogEntry = LogEntry + { leMessage :: Maybe Text, + leLevel :: Maybe Text, + leToolName :: Maybe Text, + leBatches :: Maybe [[Text]], + leMethod :: Maybe Text, + lePath :: Maybe Text + } + deriving (Show, Eq) + -- | Status of the agent for the UI data Status = Status { statusWorker :: Text, statusTask :: Maybe Text, + statusThreadId :: Maybe Text, statusFiles :: Int, statusCredits :: Double, statusTime :: Text, -- formatted time string @@ -29,6 +46,7 @@ emptyStatus workerName = Status { statusWorker = workerName, statusTask = Nothing, + statusThreadId = Nothing, statusFiles = 0, statusCredits = 0.0, statusTime = "00:00", @@ -45,10 +63,9 @@ init :: Text -> IO () init workerName = do IO.hSetBuffering IO.stderr IO.LineBuffering writeIORef currentStatus (emptyStatus workerName) - -- Reserve 2 lines at bottom - IO.hPutStrLn IO.stderr "" - IO.hPutStrLn IO.stderr "" - ANSI.hCursorUp IO.stderr 2 + -- Reserve 5 lines at bottom + replicateM_ 5 (IO.hPutStrLn IO.stderr "") + ANSI.hCursorUp IO.stderr 5 -- | Update the status update :: (Status -> Status) -> IO () @@ -60,14 +77,96 @@ update f = do updateActivity :: Text -> IO () updateActivity msg = update (\s -> s {statusActivity = msg}) +-- | Process a log line from the agent and update status if relevant +processLogLine :: Text -> IO () +processLogLine line = do + let entry = parseLine line + Data.Foldable.for_ (entry +> formatLogEntry) updateActivity + +-- | Parse a JSON log line into a LogEntry +parseLine :: Text -> Maybe LogEntry +parseLine line = do + let lbs = BL.fromStrict (TextEnc.encodeUtf8 line) + obj <- decode lbs + case obj of + Object o -> + Just + LogEntry + { leMessage = getString "message" o, + leLevel = getString "level" o, + leToolName = getString "toolName" o, + leBatches = getBatches o, + leMethod = getString "method" o, + lePath = getString "path" o + } + _ -> Nothing + where + getString k o = + case KM.lookup k o of + Just (String s) -> Just s + _ -> Nothing + + getBatches o = + case KM.lookup "batches" o of + Just (Array b) -> + Just + <| mapMaybe + ( \case + Array b0 -> + Just + <| mapMaybe + ( \case + String s -> Just s + _ -> Nothing + ) + (V.toList b0) + _ -> Nothing + ) + (V.toList b) + _ -> Nothing + +-- | Format a log entry into a user-friendly status message (NO EMOJIS) +formatLogEntry :: LogEntry -> Maybe Text +formatLogEntry LogEntry {..} = + case leMessage of + Just "executing 1 tools in 1 batch(es)" -> do + let tools = fromMaybe [] leBatches + let firstTool = case tools of + ((t : _) : _) -> t + _ -> "unknown" + Just ("THOUGHT: Planning tool execution (" <> firstTool <> ")") + Just "Tool Bash permitted - action: allow" -> + Just "TOOL: Bash command executed" + Just "Processing tool completion for ledger" + | isJust leToolName -> + Just ("TOOL: " <> fromMaybe "unknown" leToolName <> " completed") + Just "ide-fs" | leMethod == Just "readFile" -> + case lePath of + Just p -> Just ("READ: " <> p) + _ -> Nothing + Just "System prompt build complete (no changes)" -> + Just "THINKING..." + Just "System prompt build complete (first build)" -> + Just "STARTING new task context" + Just msg + | leLevel == Just "error" -> + Just ("ERROR: " <> msg) + _ -> Nothing + -- | Log a scrolling message (appears above status bars) log :: Text -> IO () log msg = do - -- Clear status bars + -- Clear status bars (5 lines) + ANSI.hClearLine IO.stderr + ANSI.hCursorDown IO.stderr 1 ANSI.hClearLine IO.stderr ANSI.hCursorDown IO.stderr 1 ANSI.hClearLine IO.stderr - ANSI.hCursorUp IO.stderr 1 + ANSI.hCursorDown IO.stderr 1 + ANSI.hClearLine IO.stderr + ANSI.hCursorDown IO.stderr 1 + ANSI.hClearLine IO.stderr + ANSI.hCursorUp IO.stderr 4 -- Print message (scrolls screen) TIO.hPutStrLn IO.stderr msg @@ -76,37 +175,43 @@ log msg = do -- (Since we scrolled, we are now on the line above where the first status line should be) render --- | Render the two status lines +-- | Render the 5 status lines (Vertical Layout) render :: IO () render = do Status {..} <- readIORef currentStatus - -- Line 1: Meta - -- [Worker: name] Task: t-123 | Files: 3 | Credits: $0.45 | Time: 05:23 let taskStr = maybe "None" identity statusTask - meta = - "[Worker: " - <> statusWorker - <> "] Task: " - <> taskStr - <> " | Files: " - <> tshow statusFiles - <> " | Credits: $" - <> str (printf "%.2f" statusCredits :: String) - <> " | Time: " - <> statusTime + threadStr = maybe "None" identity statusThreadId + + -- Line 1: Worker + Time + ANSI.hSetCursorColumn IO.stderr 0 + ANSI.hClearLine IO.stderr + TIO.hPutStr IO.stderr <| "Worker: " <> statusWorker <> " | Time: " <> statusTime + + -- Line 2: Task + ANSI.hCursorDown IO.stderr 1 + ANSI.hSetCursorColumn IO.stderr 0 + ANSI.hClearLine IO.stderr + TIO.hPutStr IO.stderr <| "Task: " <> taskStr + -- Line 3: Thread + ANSI.hCursorDown IO.stderr 1 + ANSI.hSetCursorColumn IO.stderr 0 + ANSI.hClearLine IO.stderr + TIO.hPutStr IO.stderr <| "Thread: " <> threadStr + + -- Line 4: Credits + ANSI.hCursorDown IO.stderr 1 ANSI.hSetCursorColumn IO.stderr 0 ANSI.hClearLine IO.stderr - TIO.hPutStr IO.stderr meta + TIO.hPutStr IO.stderr <| "Credits: $" <> str (printf "%.2f" statusCredits :: String) - -- Line 2: Activity - -- [14:05:22] > Thinking... + -- Line 5: Activity ANSI.hCursorDown IO.stderr 1 ANSI.hSetCursorColumn IO.stderr 0 ANSI.hClearLine IO.stderr TIO.hPutStr IO.stderr ("> " <> statusActivity) - -- Return cursor to line 1 - ANSI.hCursorUp IO.stderr 1 + -- Return cursor to Line 1 + ANSI.hCursorUp IO.stderr 4 IO.hFlush IO.stderr diff --git a/Omni/Agent/LogTest.hs b/Omni/Agent/LogTest.hs index 518147e..97b558d 100644 --- a/Omni/Agent/LogTest.hs +++ b/Omni/Agent/LogTest.hs @@ -5,7 +5,6 @@ module Omni.Agent.LogTest where import Alpha -import qualified Data.Set as Set import Omni.Agent.Log import qualified Omni.Test as Test @@ -17,9 +16,7 @@ tests = Test.group "Omni.Agent.Log" [ Test.unit "Parse LogEntry" testParse, - Test.unit "Format LogEntry" testFormat, - Test.unit "Update Status" testUpdateStatus, - Test.unit "Render Status" testRenderStatus + Test.unit "Format LogEntry" testFormat ] testParse :: IO () @@ -27,13 +24,12 @@ testParse = do let json = "{\"message\": \"executing 1 tools in 1 batch(es)\", \"batches\": [[\"grep\"]]}" let expected = LogEntry - { leMessage = "executing 1 tools in 1 batch(es)", + { leMessage = Just "executing 1 tools in 1 batch(es)", leLevel = Nothing, leToolName = Nothing, leBatches = Just [["grep"]], leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } parseLine json @?= Just expected @@ -41,84 +37,38 @@ testFormat :: IO () testFormat = do let entry = LogEntry - { leMessage = "executing 1 tools in 1 batch(es)", + { leMessage = Just "executing 1 tools in 1 batch(es)", leLevel = Nothing, leToolName = Nothing, leBatches = Just [["grep"]], leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } - format entry @?= Just "🤖 THOUGHT: Planning tool execution (grep)" + -- Expect NO emoji + formatLogEntry entry @?= Just "THOUGHT: Planning tool execution (grep)" let entry2 = LogEntry - { leMessage = "some random log", + { leMessage = Just "some random log", leLevel = Nothing, leToolName = Nothing, leBatches = Nothing, leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } - format entry2 @?= Nothing + formatLogEntry entry2 @?= Nothing let entry3 = LogEntry - { leMessage = "some error", + { leMessage = Just "some error", leLevel = Just "error", leToolName = Nothing, leBatches = Nothing, leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } - format entry3 @?= Just "❌ ERROR: some error" - -testUpdateStatus :: IO () -testUpdateStatus = do - let s0 = initialStatus "worker-1" - let e1 = - LogEntry - { leMessage = "executing 1 tools in 1 batch(es)", - leLevel = Nothing, - leToolName = Nothing, - leBatches = Just [["grep"]], - leMethod = Nothing, - lePath = Nothing, - leTimestamp = Just "12:00:00" - } - let s1 = updateStatus e1 s0 - sLastActivity s1 @?= "🤖 THOUGHT: Planning tool execution (grep)" - sStartTime s1 @?= Just "12:00:00" - - let e2 = - LogEntry - { leMessage = "ide-fs", - leLevel = Nothing, - leToolName = Nothing, - leBatches = Nothing, - leMethod = Just "readFile", - lePath = Just "/path/to/file", - leTimestamp = Just "12:00:01" - } - let s2 = updateStatus e2 s1 - sLastActivity s2 @?= "📂 READ: /path/to/file" - Set.member "/path/to/file" (sFiles s2) @?= True - sStartTime s2 @?= Just "12:00:00" -- Should preserve start time - -testRenderStatus :: IO () -testRenderStatus = do - let s = - Status - { sWorkerName = "worker-1", - sTaskId = Just "t-123", - sFiles = Set.fromList ["file1", "file2"], - sStartTime = Just "12:00", - sLastActivity = "Running..." - } - let output = renderStatus s - output @?= "[Worker: worker-1] Task: t-123 | Files: 2\nRunning..." + -- Expect NO emoji + formatLogEntry entry3 @?= Just "ERROR: some error" (@?=) :: (Eq a, Show a) => a -> a -> IO () (@?=) = (Test.@?=) diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs index 01099a0..c01a853 100644 --- a/Omni/Agent/Worker.hs +++ b/Omni/Agent/Worker.hs @@ -5,7 +5,15 @@ module Omni.Agent.Worker where import Alpha +import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Monad (forever) +import qualified Data.Aeson as Aeson +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString.Lazy as BL +import qualified Data.Scientific as Scientific import qualified Data.Text as Text +import qualified Data.Text.IO as TIO +import qualified Data.Time as Time import qualified Omni.Agent.Core as Core import qualified Omni.Agent.Git as Git import qualified Omni.Agent.Log as AgentLog @@ -13,6 +21,7 @@ import qualified Omni.Task.Core as TaskCore import qualified System.Directory as Directory import qualified System.Exit as Exit import System.FilePath ((</>)) +import qualified System.IO as IO import qualified System.Process as Process start :: Core.Worker -> IO () @@ -58,7 +67,7 @@ processTask worker task = do AgentLog.updateActivity ("Claiming task " <> tid) -- Claim task - TaskCore.updateTaskStatus tid TaskCore.InProgress + TaskCore.updateTaskStatus tid TaskCore.InProgress [] -- Commit claim locally Git.commit repo ("task: claim " <> tid) @@ -87,18 +96,20 @@ processTask worker task = do -- Run Amp AgentLog.updateActivity "Running Amp agent..." - exitCode <- runAmp repo task + (exitCode, output) <- runAmp repo task case exitCode of Exit.ExitSuccess -> do AgentLog.log "Agent finished successfully" -- Update status to Review (bundled with feature commit) - TaskCore.updateTaskStatus tid TaskCore.Review + TaskCore.updateTaskStatus tid TaskCore.Review [] -- Commit changes - -- We should check if there are changes, but 'git add .' is safe. - Git.commit repo ("feat: implement " <> tid) + -- We use the agent's output as the extended commit description + let summary = Text.strip output + let commitMsg = "feat: implement " <> tid <> "\n\n" <> summary + Git.commit repo commitMsg -- Submit for review AgentLog.updateActivity "Submitting for review..." @@ -111,18 +122,17 @@ processTask worker task = do Git.syncWithLive repo -- Update status to Review (for signaling) - TaskCore.updateTaskStatus tid TaskCore.Review + TaskCore.updateTaskStatus tid TaskCore.Review [] Git.commit repo ("task: review " <> tid) - + AgentLog.log ("[✓] Task " <> tid <> " completed") AgentLog.update (\s -> s {AgentLog.statusTask = Nothing}) - Exit.ExitFailure code -> do AgentLog.log ("Agent failed with code " <> tshow code) AgentLog.updateActivity "Agent failed, retrying..." threadDelay (10 * 1000000) -- Sleep 10s -runAmp :: FilePath -> TaskCore.Task -> IO Exit.ExitCode +runAmp :: FilePath -> TaskCore.Task -> IO (Exit.ExitCode, Text) runAmp repo task = do let prompt = "You are a Worker Agent.\n" @@ -134,7 +144,8 @@ runAmp repo task = do <> "3. Run tests to verify your work (e.g., 'bild --test Omni/Namespace').\n" <> "4. Fix any errors found during testing.\n" <> "5. Do NOT update the task status or manage git branches (the system handles that).\n" - <> "6. When finished and tested, exit.\n\n" + <> "6. Do NOT run 'git commit'. The system will commit your changes automatically.\n" + <> "7. When finished and tested, exit.\n\n" <> "Context:\n" <> "- You are working in '" <> Text.pack repo @@ -144,13 +155,37 @@ runAmp repo task = do <> "'.\n" Directory.createDirectoryIfMissing True (repo </> "_/llm") + let logPath = repo </> "_/llm/amp.log" + + -- Ensure log file is empty/exists + IO.writeFile logPath "" + + -- Read AGENTS.md + agentsMd <- + fmap (fromMaybe "") <| do + exists <- Directory.doesFileExist (repo </> "AGENTS.md") + if exists + then Just </ readFile (repo </> "AGENTS.md") + else pure Nothing + + let fullPrompt = + prompt + <> "\n\nREPOSITORY GUIDELINES (AGENTS.md):\n" + <> agentsMd + + -- Monitor log file + tidLog <- forkIO (monitorLog logPath) -- Assume amp is in PATH - let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack prompt] + let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack fullPrompt] let cp = (Process.proc "amp" args) {Process.cwd = Just repo} - (_, _, _, ph) <- Process.createProcess cp - Process.waitForProcess ph + (exitCode, out, _err) <- Process.readCreateProcessWithExitCode cp "" + + -- Cleanup + killThread tidLog + + pure (exitCode, Text.pack out) formatTask :: TaskCore.Task -> Text formatTask t = @@ -202,3 +237,23 @@ findBaseBranch repo task = do case candidates of (candidate : _) -> pure ("task/" <> TaskCore.depId candidate) [] -> pure "live" + +monitorLog :: FilePath -> IO () +monitorLog path = do + -- Wait for file to exist + waitForFile path + + IO.withFile path IO.ReadMode <| \h -> do + IO.hSetBuffering h IO.LineBuffering + forever <| do + eof <- IO.hIsEOF h + if eof + then threadDelay 100000 -- 0.1s + else do + line <- TIO.hGetLine h + AgentLog.processLogLine line + +waitForFile :: FilePath -> IO () +waitForFile p = do + e <- Directory.doesFileExist p + if e then pure () else threadDelay 100000 >> waitForFile p |
