commit db61b5544ff31cf88f49a8758a88110f799cc34e
Author: Coder Agent <coder@agents.omni>
Date: Mon Apr 6 20:42:32 2026
agent: add null-delimited stdin mode and persistent session
Split Omni/Agent into oneshot argv mode and no-arg stdin mode.
No-arg mode reads UTF-8 prompts delimited by NUL bytes.
Thread AgentState across prompts so context accumulates between turns.
Update Op Agent program init to append user prompts to existing state
instead of resetting each run, and reset per-turn flags.
Add regression coverage for multi-turn accumulation in
Omni/Agent/Programs/Agent.hs.
Document that EOF flushes trailing buffered bytes as a final prompt.
This intentionally allows prompt delimiters to be NUL or EOF.
Task-Id: t-759.1
diff --git a/Omni/Agent.hs b/Omni/Agent.hs
index 3f4c9065..6d2b85b9 100755
--- a/Omni/Agent.hs
+++ b/Omni/Agent.hs
@@ -40,6 +40,7 @@ import Alpha
import qualified Control.Exception as Exception
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import qualified Data.IORef as IORef
import qualified Data.Text as Text
@@ -191,7 +192,7 @@ runParser =
<*> Cli.optional
( Cli.strArgument
( Cli.metavar "PROMPT"
- <> Cli.help "Prompt text (or read from stdin if absent)"
+ <> Cli.help "Prompt text or script path (omit for \0-delimited stdin prompt mode)"
)
)
)
@@ -223,21 +224,25 @@ runParser =
-- | Execute agent run with parsed options
runWithOptions :: AgentOptions -> IO ()
-runWithOptions baseOpts = do
- -- Get prompt from arg or stdin
+runWithOptions baseOpts =
+ case optPromptArg baseOpts of
+ Just _ -> runOneShotWithPromptArg baseOpts
+ Nothing -> runNullDelimitedStdinMode baseOpts
+
+runOneShotWithPromptArg :: AgentOptions -> IO ()
+runOneShotWithPromptArg baseOpts = do
+ -- Get prompt from arg
-- If arg is a file path (e.g., shebang invocation), read file content
- let promptArg = optPromptArg baseOpts
- (argPrompt, argPromptPath) <- case promptArg of
- Nothing -> pure ("", Nothing)
- Just arg -> do
- -- Check if it's a file path (for shebang support: ./script.md)
- isFile <- doesFileExist arg
- if isFile && (".md" `isSuffixOf` arg || ".markdown" `isSuffixOf` arg)
- then do
- content <- TextIO.readFile arg
- -- Strip shebang line if present
- pure (stripShebang content, Just arg)
- else pure (Text.pack arg, Nothing)
+ let promptArg = fromMaybe "" (optPromptArg baseOpts)
+ (argPrompt, argPromptPath) <- do
+ -- Check if it's a file path (for shebang support: ./script.md)
+ isFile <- doesFileExist promptArg
+ if isFile && (".md" `isSuffixOf` promptArg || ".markdown" `isSuffixOf` promptArg)
+ then do
+ content <- TextIO.readFile promptArg
+ -- Strip shebang line if present
+ pure (stripShebang content, Just promptArg)
+ else pure (Text.pack promptArg, Nothing)
-- Always read stdin if not a terminal (allows piping data to prompts)
stdinContent <- do
@@ -245,6 +250,7 @@ runWithOptions baseOpts = do
if isTerminal
then pure ""
else TextIO.getContents
+
-- Combine: if we have stdin, append it to prompt; if no prompt, use stdin
let prompt
| Text.null stdinContent = argPrompt
@@ -257,7 +263,6 @@ runWithOptions baseOpts = do
optPromptPath = argPromptPath
}
- -- Check for dry-run
when (optDryRun opts) <| do
TextIO.hPutStrLn IO.stderr "Dry run - would use options:"
TextIO.hPutStrLn IO.stderr <| " Provider: " <> optProvider opts
@@ -268,11 +273,69 @@ runWithOptions baseOpts = do
TextIO.hPutStrLn IO.stderr <| " Prompt: " <> Text.take 100 (optPrompt opts)
Exit.exitSuccess
- -- Run agent
result <- runAgent opts
+ emitRunResult opts True result
- -- Output result
- -- Note: if --json flag, events were already streamed, so output compact JSON for final result
+runNullDelimitedStdinMode :: AgentOptions -> IO ()
+runNullDelimitedStdinMode baseOpts = do
+ let opts =
+ baseOpts
+ { optPrompt = "",
+ optPromptPath = Nothing
+ }
+ when (optDryRun opts) <| do
+ TextIO.hPutStrLn IO.stderr "Dry run - would use options:"
+ TextIO.hPutStrLn IO.stderr <| " Provider: " <> optProvider opts
+ TextIO.hPutStrLn IO.stderr <| " Model: " <> fromMaybe "default" (optModel opts)
+ TextIO.hPutStrLn IO.stderr <| " Max cost: " <> tshow (optMaxCost opts) <> " cents"
+ TextIO.hPutStrLn IO.stderr <| " Max iter: " <> tshow (optMaxIter opts)
+ TextIO.hPutStrLn IO.stderr <| " Verbosity: " <> tshow (optVerbosity opts)
+ TextIO.hPutStrLn IO.stderr " Prompt source: stdin (\\0-delimited)"
+ Exit.exitSuccess
+
+ initialState <- loadInitialAgentState opts
+ runNullDelimitedPromptLoop (opts {optResume = Nothing}) initialState ""
+
+runNullDelimitedPromptLoop :: AgentOptions -> OpAgent.AgentState -> BS.ByteString -> IO ()
+runNullDelimitedPromptLoop opts sessionState buffered = do
+ inputChunk <- BS.hGetSome IO.stdin 4096
+ if BS.null inputChunk
+ then do
+ -- Intentional: EOF flushes any buffered trailing bytes as a final prompt,
+ -- so prompts may be delimited by \0 or EOF.
+ _ <- runPromptChunk opts sessionState buffered
+ pure ()
+ else do
+ let (promptChunks, remainder) = splitNullDelimitedPrompts (buffered <> inputChunk)
+ sessionState' <- foldM (runPromptChunk opts) sessionState promptChunks
+ runNullDelimitedPromptLoop opts sessionState' remainder
+
+runPromptChunk :: AgentOptions -> OpAgent.AgentState -> BS.ByteString -> IO OpAgent.AgentState
+runPromptChunk opts sessionState promptBytes
+ | BS.null promptBytes = pure sessionState
+ | otherwise =
+ case TE.decodeUtf8' promptBytes of
+ Left err -> do
+ TextIO.hPutStrLn IO.stderr <| "Error: stdin prompt is not valid UTF-8: " <> tshow err
+ Exit.exitWith (Exit.ExitFailure 1)
+ Right prompt -> do
+ (result, sessionState') <- runAgentWithState (opts {optPrompt = prompt, optPromptPath = Nothing}) sessionState
+ emitRunResult opts False result
+ pure sessionState'
+
+splitNullDelimitedPrompts :: BS.ByteString -> ([BS.ByteString], BS.ByteString)
+splitNullDelimitedPrompts = go []
+ where
+ go acc bytes =
+ case BS.elemIndex 0 bytes of
+ Nothing -> (reverse acc, bytes)
+ Just idx ->
+ let (promptChunk, restWithDelimiter) = BS.splitAt idx bytes
+ rest = BS.drop 1 restWithDelimiter
+ in go (promptChunk : acc) rest
+
+emitRunResult :: AgentOptions -> Bool -> AgentResult -> IO ()
+emitRunResult opts exitOnError result =
if optJson opts
then do
BL.putStr <| Aeson.encode result
@@ -281,7 +344,7 @@ runWithOptions baseOpts = do
AgentSuccess response -> TextIO.putStrLn response
AgentError err -> do
TextIO.hPutStrLn IO.stderr <| "Error: " <> err
- Exit.exitWith (Exit.ExitFailure 1)
+ when exitOnError <| Exit.exitWith (Exit.ExitFailure 1)
-- | Load AGENTS.md or CLAUDE.md from cwd and all parent directories.
-- Returns list of (path, content) pairs, with topmost parent first.
@@ -628,6 +691,26 @@ applyWorkflowMeta opts meta =
-- | Run the agent with given options
runAgent :: AgentOptions -> IO AgentResult
runAgent opts = do
+ initialState <- loadInitialAgentState opts
+ (result, _finalState) <- runAgentWithState opts initialState
+ pure result
+
+loadInitialAgentState :: AgentOptions -> IO OpAgent.AgentState
+loadInitialAgentState opts =
+ case optResume opts of
+ Nothing -> pure OpAgent.initialAgentState
+ Just resumePath -> do
+ loadResult <- loadCheckpoint resumePath
+ case loadResult of
+ Left err -> do
+ TextIO.hPutStrLn IO.stderr <| "[resume] Failed to load checkpoint: " <> err
+ pure OpAgent.initialAgentState
+ Right cp -> do
+ TextIO.hPutStrLn IO.stderr <| "[resume] Resuming from checkpoint: " <> cpName cp
+ pure (cpState cp)
+
+runAgentWithState :: AgentOptions -> OpAgent.AgentState -> IO (AgentResult, OpAgent.AgentState)
+runAgentWithState opts initialState = do
let verbosity = optVerbosity opts
emitTraceEvent :: Trace.Event -> IO ()
emitTraceEvent event = do
@@ -639,12 +722,15 @@ runAgent opts = do
when (verbosity >= 2 || optJson opts) <| do
BL.putStr (Aeson.encode event)
TextIO.putStrLn ""
- emitError :: Text -> IO AgentResult
- emitError err = do
+ emitErrorWithState :: OpAgent.AgentState -> Text -> IO (AgentResult, OpAgent.AgentState)
+ emitErrorWithState agentState err = do
when (verbosity >= 2 || optJson opts) <| do
now <- Time.getCurrentTime
emitTraceEvent (Trace.EventCustom "agent_error" (Aeson.object ["message" Aeson..= err]) now)
- pure <| AgentError err
+ pure (AgentError err, agentState)
+
+ emitError :: Text -> IO (AgentResult, OpAgent.AgentState)
+ emitError = emitErrorWithState initialState
shutdownRequested <- IORef.newIORef False
shutdownHandled <- IORef.newIORef False
@@ -663,12 +749,8 @@ runAgent opts = do
Left err -> emitError err
Right (maybeMeta, promptBodyRaw) -> do
let promptBody = Text.strip promptBodyRaw
- when (Text.null promptBody) <| do
- TextIO.hPutStrLn IO.stderr "Error: No prompt provided"
- Exit.exitWith (Exit.ExitFailure 1)
-
- let opts' = maybe opts (applyWorkflowMeta opts) maybeMeta
- let workflowTools =
+ opts' = maybe opts (applyWorkflowMeta opts) maybeMeta
+ workflowTools =
case maybeMeta of
Nothing -> []
Just meta ->
@@ -917,40 +999,27 @@ runAgent opts = do
Seq.seqHydrationConfig = mHydrationConfig
}
- -- Determine initial state (from checkpoint if resuming)
- initialState <- case optResume opts' of
- Nothing -> pure OpAgent.initialAgentState
- Just resumePath -> do
- loadResult <- loadCheckpoint resumePath
- case loadResult of
- Left err -> do
- TextIO.hPutStrLn IO.stderr <| "[resume] Failed to load checkpoint: " <> err
- pure OpAgent.initialAgentState
- Right cp -> do
- TextIO.hPutStrLn IO.stderr <| "[resume] Resuming from checkpoint: " <> cpName cp
- pure (cpState cp)
-
-- Run agent
result <- Seq.runSequential seqConfig initialState (OpAgent.runAgent opConfig promptBody)
shutdownRequested' <- IORef.readIORef shutdownRequested
case result of
Left err ->
if shutdownRequested' && err == "Cancelled by user"
- then pure <| AgentSuccess ""
+ then pure (AgentSuccess "", initialState)
else emitError err
- Right (agentResult, _trace, _finalState) ->
+ Right (agentResult, _trace, finalState) ->
case OpAgent.arError agentResult of
- Just err -> emitError err
+ Just err -> emitErrorWithState finalState err
Nothing ->
let response = OpAgent.arFinalMessage agentResult
maxIterReached = optMaxIter opts' > 0 && response == "Maximum iterations reached"
cancelled = response == "Cancelled by user"
in if cancelled && shutdownRequested'
- then pure <| AgentSuccess ""
+ then pure (AgentSuccess "", finalState)
else
if maxIterReached || cancelled
- then emitError response
- else pure <| AgentSuccess response
+ then emitErrorWithState finalState response
+ else pure (AgentSuccess response, finalState)
engineToolToSeq :: Engine.Tool -> Seq.Tool
engineToolToSeq tool =
diff --git a/Omni/Agent/Programs/Agent.hs b/Omni/Agent/Programs/Agent.hs
index 4709a8ab..33f1b5cb 100644
--- a/Omni/Agent/Programs/Agent.hs
+++ b/Omni/Agent/Programs/Agent.hs
@@ -246,12 +246,17 @@ runAgent config userPrompt = do
now
)
- -- Initialize state with system + user messages
- let initialMsgs =
- [ systemMessage (acSystemPrompt config),
- userMessage userPrompt
- ]
- Op.put initialAgentState {asMessages = initialMsgs}
+ -- Initialize/extend state with this turn's user message.
+ -- If state already has messages, preserve the session and append.
+ -- If state is empty, seed with system + first user message.
+ st0 <- Op.get
+ let baseMessages =
+ if null (asMessages st0)
+ then [systemMessage (acSystemPrompt config)]
+ else asMessages st0
+ nextMessages = baseMessages <> [userMessage userPrompt]
+ nextState = st0 {asMessages = nextMessages, asHitMaxIterations = False}
+ Op.put nextState
-- Checkpoint before starting
Op.checkpoint "init"
@@ -465,10 +470,28 @@ test =
case result of
Left err -> Test.assertFailure ("Failed: " <> str err)
Right (agentResult, _, finalState) -> do
- -- Agent should complete
arError agentResult Test.@=? Nothing
- -- Final message should be from mock
arFinalMessage agentResult Test.@=? "Hello! I'm done."
- -- Should have completed on iteration 0 (first iteration = 0)
- asIteration finalState Test.@=? 0
+ asIteration finalState Test.@=? 0,
+ Test.unit "runAgent appends to existing session state" <| do
+ provider <- Provider.mockProvider [Provider.MockText "first reply", Provider.MockText "second reply"]
+ let config =
+ defaultAgentConfig
+ { acBudget = Nothing,
+ acMaxIterations = 5
+ }
+ seqConfig = Seq.defaultSeqConfig provider []
+
+ firstTurn <- Seq.runSequential seqConfig initialAgentState (runAgent config "first prompt")
+ case firstTurn of
+ Left err -> Test.assertFailure ("First turn failed: " <> str err)
+ Right (_, _, stateAfterFirst) -> do
+ length (asMessages stateAfterFirst) Test.@=? 3
+
+ secondTurn <- Seq.runSequential seqConfig stateAfterFirst (runAgent config "second prompt")
+ case secondTurn of
+ Left err -> Test.assertFailure ("Second turn failed: " <> str err)
+ Right (_, _, stateAfterSecond) -> do
+ map Op.pmRole (asMessages stateAfterSecond)
+ Test.@=? ["system", "user", "assistant", "user", "assistant"]
]