baikai-0.5.0.0: src/Baikai/Agent.hs
-- | Provider-neutral types for unattended coding-agent runs with
-- local agent CLIs such as Claude Code and Codex.
--
-- An unattended run starts the coding agent with no terminal and no
-- human present, lets it drive its own internal tool loop, allows it
-- to change files inside directories the caller explicitly authorized,
-- and collects a process result. It is neither a completion (the
-- interesting output is the changed working tree, not the text) nor an
-- interactive launch (nobody is watching).
--
-- This module deliberately does not implement process spawning, and it
-- renders no command-line flags. The core package owns the shared
-- vocabulary and the pure policy algebra, while vendor packages own the
-- translation into their CLI's arguments and a separate package owns
-- the process runner.
--
-- This module is not re-exported from "Baikai". Its field accessors
-- deliberately share names with "Baikai.Interactive", so import it
-- directly, qualified if you need both surfaces at once.
module Baikai.Agent
( -- * Provider identity
AgentProvider (..),
renderAgentProvider,
parseAgentProvider,
-- * Capability profile
AgentCapability (..),
renderAgentCapability,
parseAgentCapability,
-- * Requested safety policy
AgentSafety (capability, allowedTools, providerArgs),
agentSafety,
-- * Output discipline
AgentOutputMode (..),
renderAgentOutputMode,
parseAgentOutputMode,
AgentCapturedOutput (..),
capturedBytes,
-- * The unattended run request
AgentRunRequest
( provider,
prompt,
modelId,
effort,
workingDir,
extraDirs,
safety,
timeout,
output,
outputLimit,
envPassthrough
),
agentRunRequest,
-- * The operator policy ceiling
AgentCeiling (maxCapability, allowProviderArgs, allowedProviders),
defaultAgentCeiling,
CeilingViolation (..),
renderCeilingViolation,
applyAgentCeiling,
-- * The rendered command
AgentPromptTransport (..),
AgentCommand (..),
-- * The run result
AgentRunResult,
agentRunResult,
AgentRunOutcome (..),
agentRunOutcome,
-- * Failures
AgentRenderError (..),
renderAgentRenderError,
AgentRunFailure (..),
renderAgentRunFailure,
)
where
import Baikai.Evidence (ModelCallEvidence)
import Baikai.Prelude
import Baikai.ThinkingLevel (ThinkingLevel)
import Data.ByteString (ByteString)
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime)
import System.Exit (ExitCode)
-- | Local coding-agent tools Baikai can describe without depending on
-- a vendor package. The names match 'Baikai.Interactive.InteractiveProvider'
-- so both surfaces spell the same tool identically.
data AgentProvider
= AgentClaude
| AgentCodex
deriving stock (Eq, Ord, Show, Generic)
renderAgentProvider :: AgentProvider -> Text
renderAgentProvider AgentClaude = "claude"
renderAgentProvider AgentCodex = "codex"
-- | Parse a canonical provider name. Matching is exact and
-- case-sensitive: @\"Claude\"@ is not a provider.
parseAgentProvider :: Text -> Maybe AgentProvider
parseAgentProvider "claude" = Just AgentClaude
parseAgentProvider "codex" = Just AgentCodex
parseAgentProvider _ = Nothing
-- | How much authority an unattended run gets, expressed
-- provider-neutrally. Constructors ascend in authority, and the
-- 'Ord' instance derived from that order is what 'applyAgentCeiling'
-- compares against an operator's permitted maximum — do not reorder
-- them.
--
-- * 'AgentReadOnly': the run may read but must not modify anything.
-- * 'AgentEditWorkspace': the run may modify files inside its working
-- directory and its explicit extra directories, and nowhere else.
-- * 'AgentFullAccess': no sandbox at all. This is why an operator
-- ceiling refuses it by default.
data AgentCapability
= AgentReadOnly
| AgentEditWorkspace
| AgentFullAccess
deriving stock (Eq, Ord, Show, Generic)
renderAgentCapability :: AgentCapability -> Text
renderAgentCapability AgentReadOnly = "read-only"
renderAgentCapability AgentEditWorkspace = "edit-workspace"
renderAgentCapability AgentFullAccess = "full-access"
-- | Parse a canonical capability name. Matching is exact and
-- case-sensitive.
parseAgentCapability :: Text -> Maybe AgentCapability
parseAgentCapability "read-only" = Just AgentReadOnly
parseAgentCapability "edit-workspace" = Just AgentEditWorkspace
parseAgentCapability "full-access" = Just AgentFullAccess
parseAgentCapability _ = Nothing
-- | The safety policy a job asks for, as opposed to what an operator
-- permits.
data AgentSafety = AgentSafety
{ -- | How much filesystem authority the run requests.
capability :: !AgentCapability,
-- | Optional narrowing of the provider's tool set. An empty list
-- means \"do not restrict tools beyond what the capability
-- implies\"; a non-empty list is rendered where the provider
-- supports a tool allow-list.
allowedTools :: ![Text],
-- | Raw provider arguments Baikai does not model, passed through
-- verbatim. This is a privileged channel: arbitrary vendor flags
-- can widen authority in ways no capability profile can see, so an
-- operator ceiling gates the channel as a whole. Nothing here
-- inspects these strings for dangerous flags, and nothing should:
-- flag spellings change, and a denylist that misses one provides
-- false confidence rather than a security boundary.
providerArgs :: ![Text]
}
deriving stock (Eq, Show, Generic)
-- | A safety request for the given capability, with no tool narrowing
-- and no raw provider arguments.
agentSafety :: AgentCapability -> AgentSafety
agentSafety cap =
AgentSafety
{ capability = cap,
allowedTools = [],
providerArgs = []
}
-- | What Baikai does with the child process's output streams.
--
-- * 'InheritOutput': the child writes straight to the parent's own
-- streams and Baikai captures nothing.
-- * 'CaptureOutput': Baikai collects the bytes and the parent sees
-- nothing.
-- * 'TeeOutput': both.
data AgentOutputMode
= InheritOutput
| CaptureOutput
| TeeOutput
deriving stock (Eq, Ord, Show, Generic)
renderAgentOutputMode :: AgentOutputMode -> Text
renderAgentOutputMode InheritOutput = "inherit"
renderAgentOutputMode CaptureOutput = "capture"
renderAgentOutputMode TeeOutput = "tee"
-- | Parse a canonical output-mode name. Matching is exact and
-- case-sensitive.
parseAgentOutputMode :: Text -> Maybe AgentOutputMode
parseAgentOutputMode "inherit" = Just InheritOutput
parseAgentOutputMode "capture" = Just CaptureOutput
parseAgentOutputMode "tee" = Just TeeOutput
parseAgentOutputMode _ = Nothing
-- | One captured stream of a finished run. The three states are
-- distinct on purpose: under 'InheritOutput' the bytes went to the
-- parent's terminal and none exist to report, which an empty
-- 'ByteString' could not distinguish from a command that legitimately
-- printed nothing.
data AgentCapturedOutput
= -- | The stream was not captured.
OutputNotCaptured
| -- | The stream was captured in full.
OutputCaptured !ByteString
| -- | The stream was captured up to the byte limit; more existed.
OutputTruncated !ByteString
deriving stock (Eq, Show, Generic)
-- | The captured bytes, if any were captured at all.
capturedBytes :: AgentCapturedOutput -> Maybe ByteString
capturedBytes OutputNotCaptured = Nothing
capturedBytes (OutputCaptured bytes) = Just bytes
capturedBytes (OutputTruncated bytes) = Just bytes
-- | Everything an unattended coding-agent run needs, expressed
-- provider-neutrally. This is the single source of truth for every
-- process-level setting: the working directory, the timeout, the output
-- discipline, the output limit, and the declared environment
-- variables.
data AgentRunRequest = AgentRunRequest
{ -- | Which coding-agent tool to run.
provider :: !AgentProvider,
-- | The instruction handed to the coding agent.
prompt :: !Text,
-- | Model override, or 'Nothing' to leave the tool's default.
modelId :: !(Maybe Text),
-- | Reasoning-effort override, or 'Nothing' to leave the tool's
-- default.
effort :: !(Maybe ThinkingLevel),
-- | The directory the run is rooted in. Required, not optional:
-- the safety contract is that a run gets no filesystem authority
-- beyond this directory and 'extraDirs', and that sentence has no
-- meaning if the root can be absent.
workingDir :: !FilePath,
-- | Directories this run may reach beyond 'workingDir'. The
-- precise authority is provider-dependent: Claude Code's
-- @--add-dir@ grants tool access, while @codex exec@'s @--add-dir@
-- grants write access alongside the primary workspace.
extraDirs :: ![FilePath],
-- | The safety policy this job asks for.
safety :: !AgentSafety,
-- | Wall-clock limit for the whole run, or 'Nothing' for no limit.
timeout :: !(Maybe NominalDiffTime),
-- | What to do with the child's output streams.
output :: !AgentOutputMode,
-- | Maximum captured bytes per stream, not in total. 'Nothing'
-- means unbounded.
outputLimit :: !(Maybe Int),
-- | Names of environment variables this job declares it requires.
-- These are names only, never name\/value pairs, so the list
-- cannot contain a secret by construction. It is not an allow-list
-- and does not restrict the child's environment: the child
-- inherits the parent's environment in full, because both coding
-- agents need @HOME@, @PATH@, and their own credential files to
-- function. What the list buys is a precondition check — a runner
-- fails before spawning when a declared variable is unset or
-- empty, so a misconfigured job produces one clear error instead
-- of a coding agent that starts and then flails.
envPassthrough :: ![Text]
}
deriving stock (Eq, Show, Generic)
-- | An unattended run of the given provider, rooted in the given
-- working directory, with the given prompt. Everything else defaults
-- to the least-authority, least-surprising value: no model or effort
-- override, no extra directories, read-only capability, no timeout,
-- inherited output, no output limit, and no declared environment
-- variables.
--
-- The capability default is 'AgentReadOnly': a caller who wants to
-- change files must say so. That is independent of an operator
-- ceiling, which says what a caller is /allowed/ to ask for.
agentRunRequest :: AgentProvider -> FilePath -> Text -> AgentRunRequest
agentRunRequest p dir userPrompt =
AgentRunRequest
{ provider = p,
prompt = userPrompt,
modelId = Nothing,
effort = Nothing,
workingDir = dir,
extraDirs = [],
safety = agentSafety AgentReadOnly,
timeout = Nothing,
output = InheritOutput,
outputLimit = Nothing,
envPassthrough = []
}
-- | The limit an operator places on what any job may request.
--
-- A job description can come from a repository the operator did not
-- write, which makes it untrusted input: it could ask for unlimited
-- filesystem access. A ceiling is a separate, operator-owned value
-- that bounds what any job may ask for, and 'applyAgentCeiling' is the
-- pure check.
data AgentCeiling = AgentCeiling
{ -- | The highest capability any job may request.
maxCapability :: !AgentCapability,
-- | Whether jobs may pass raw provider arguments at all. The whole
-- channel is privileged, so it is permitted or refused as a unit
-- rather than filtered.
allowProviderArgs :: !Bool,
-- | The providers jobs may select. An empty list permits __no__
-- provider; it does not mean \"all providers\".
allowedProviders :: ![AgentProvider]
}
deriving stock (Eq, Show, Generic)
-- | The ceiling in force when an operator has supplied no policy of
-- their own: a job may ask for read-only or edit-workspace authority,
-- may not ask for full access, and may not pass raw provider
-- arguments; both providers are permitted.
--
-- An edit-capable default is the only one under which a job that
-- changes files works on a fresh machine with no out-of-band setup,
-- while the two things that can widen authority without bound —
-- sandbox-bypassing modes and arbitrary vendor flags — stay opt-in at
-- operator scope.
defaultAgentCeiling :: AgentCeiling
defaultAgentCeiling =
AgentCeiling
{ maxCapability = AgentEditWorkspace,
allowProviderArgs = False,
allowedProviders = [AgentClaude, AgentCodex]
}
-- | One way a request exceeded a ceiling.
data CeilingViolation
= -- | The requested capability, then the permitted maximum. The
-- order matters: reversing the pair produces a message that blames
-- the wrong side.
CapabilityExceeded !AgentCapability !AgentCapability
| -- | The raw provider arguments that were requested while the
-- channel is closed, in the order given.
--
-- __Do not render these values.__ This is the one field of a job
-- description an operator could write a credential into, which is
-- why the configuration layer classifies it secret; a refusal
-- message that quoted them would defeat that classification, so
-- 'renderCeilingViolation' reports how many were requested and not
-- what they were. The list is retained rather than reduced to a
-- count because a programmatic caller may legitimately need to
-- inspect it.
ProviderArgsForbidden ![Text]
| -- | The requested provider, then the permitted providers.
ProviderForbidden !AgentProvider ![AgentProvider]
deriving stock (Eq, Show, Generic)
-- | One line of plain English naming what was asked for and what is
-- permitted.
renderCeilingViolation :: CeilingViolation -> Text
renderCeilingViolation (CapabilityExceeded requested permitted) =
"requested capability "
<> renderAgentCapability requested
<> " exceeds the permitted maximum "
<> renderAgentCapability permitted
renderCeilingViolation (ProviderArgsForbidden args) =
"raw provider arguments are not permitted; "
<> Text.pack (show (length args))
<> " requested, and their values are secret and are not shown"
renderCeilingViolation (ProviderForbidden requested permitted) =
"provider "
<> renderAgentProvider requested
<> " is not permitted; permitted providers: "
<> renderPermittedProviders permitted
where
renderPermittedProviders [] = "none"
renderPermittedProviders ps = Text.intercalate ", " (map renderAgentProvider ps)
-- | Check a request against a ceiling. Returns the request
-- __unchanged__ when it is within the ceiling, and every violation
-- when it is not.
--
-- Two properties are deliberate. The request is never modified to fit
-- the ceiling: a job that asked for more authority than it may have is
-- an error to report, not a request to quietly weaken, because silent
-- clamping is how a job that believes it may edit ends up doing
-- nothing and reporting success. And every violation is collected
-- rather than only the first, so an operator fixing a job description
-- sees all of them in one run.
--
-- This function does not inspect the contents of the requested
-- 'providerArgs'. See that field's documentation for why a denylist of
-- dangerous flags would be false confidence rather than a boundary.
applyAgentCeiling :: AgentCeiling -> AgentRunRequest -> Either [CeilingViolation] AgentRunRequest
applyAgentCeiling limit request
| null violations = Right request
| otherwise = Left violations
where
requestedProvider = request ^. #provider
permittedProviders = limit ^. #allowedProviders
requestedCapability = request ^. #safety . #capability
permittedCapability = limit ^. #maxCapability
requestedArgs = request ^. #safety . #providerArgs
violations =
concat
[ [ ProviderForbidden requestedProvider permittedProviders
| requestedProvider `notElem` permittedProviders
],
[ CapabilityExceeded requestedCapability permittedCapability
| requestedCapability > permittedCapability
],
[ ProviderArgsForbidden requestedArgs
| not (null requestedArgs),
not (limit ^. #allowProviderArgs)
]
]
-- | How the prompt reaches the child process.
data AgentPromptTransport
= -- | The prompt is written to the child's standard input and
-- appears nowhere in the argument vector.
PromptOnStdin
| -- | The prompt is already the final element of the argument
-- vector, protected by the provider's @--@ separator, and the
-- child gets no standard input at all.
PromptAsArgument
deriving stock (Eq, Ord, Show, Generic)
-- | A rendered provider command: the boundary value between a vendor
-- renderer, which produces it, and a process runner, which consumes
-- it. It lives in the core package so that neither side depends on the
-- other.
--
-- Honor 'promptTransport' exactly. @codex exec@ documents that a piped
-- standard input /and/ a positional prompt are both used, with
-- standard input appended as a @\<stdin\>@ block, so emitting both is
-- a silent corruption of the instruction. Making the transport an
-- explicit choice turns that hazard into a type-level distinction
-- rather than a convention.
--
-- This type deliberately carries no working directory. Claude Code has
-- no working-directory flag at all, so for one of the two providers the
-- working directory can only ever be a process-level setting; a runner
-- therefore reads it from 'AgentRunRequest' and takes both values.
-- Duplicating it here was rejected because two copies of a working
-- directory can disagree, and that disagreement would be a sandbox
-- escape rather than a cosmetic bug.
data AgentCommand = AgentCommand
{ -- | The program to run, either a bare name resolved on @PATH@ or
-- an explicit path.
executable :: !FilePath,
-- | The rendered argument vector, excluding the program name.
arguments :: ![String],
-- | Where the prompt travels.
promptTransport :: !AgentPromptTransport,
-- | The prompt itself, for a runner that must write it to standard
-- input.
promptText :: !Text
}
deriving stock (Eq, Show, Generic)
-- | The process-level outcome of a finished unattended run. Read it
-- with @generic-lens@ labels, for example @result ^. #exitCode@.
--
-- A non-zero exit code is a normal result and lives here rather than
-- in a failure type: a coding agent that fails its task and exits 1
-- has still run.
data AgentRunResult = AgentRunResult
{ -- | Which coding-agent tool ran.
provider :: !AgentProvider,
-- | The child's exit status.
exitCode :: !ExitCode,
-- | The child's standard output, per the request's output mode.
stdout :: !AgentCapturedOutput,
-- | The child's standard error, per the request's output mode.
stderr :: !AgentCapturedOutput,
-- | How long the run took.
duration :: !NominalDiffTime
}
deriving stock (Eq, Show, Generic)
-- | A result with both streams marked 'OutputNotCaptured'.
agentRunResult :: AgentProvider -> ExitCode -> NominalDiffTime -> AgentRunResult
agentRunResult p code elapsed =
AgentRunResult
{ provider = p,
exitCode = code,
stdout = OutputNotCaptured,
stderr = OutputNotCaptured,
duration = elapsed
}
-- | Everything one finished unattended run produced: what happened, and
-- the evidence the runner built for it.
--
-- The two are siblings rather than the evidence living inside
-- 'AgentRunResult', because the run that most needs a record is one that
-- did not produce a result. A run killed by its own timeout started, ran,
-- consumed tokens, and possibly changed the working tree, and it reports
-- @Left ('RunTimedOut' …)@ — so evidence hanging off the @Right@ would be
-- unreachable in exactly the case an operator most wants it.
--
-- 'evidence' is 'Nothing' in two situations that must not be confused.
-- The caller asked for none, which is the default and costs nothing. Or
-- nothing ever started — a missing working directory, an unset declared
-- environment variable, an executable that could not be spawned — and
-- there is no run to describe.
data AgentRunOutcome = AgentRunOutcome
{ outcome :: !(Either AgentRunFailure AgentRunResult),
evidence :: !(Maybe ModelCallEvidence)
}
deriving stock (Eq, Show, Generic)
-- | An outcome carrying no evidence, for the paths where none was asked
-- for or none exists.
agentRunOutcome :: Either AgentRunFailure AgentRunResult -> AgentRunOutcome
agentRunOutcome result = AgentRunOutcome {outcome = result, evidence = Nothing}
-- | A refusal raised before any process is created: the requested
-- policy cannot be expressed honestly for the chosen provider, so the
-- run must not start.
--
-- Every constructor that reports an inexpressible policy carries a
-- human-readable explanation, because a refusal that does not say
-- /why/ is a dead end rather than an error an operator can act on.
data AgentRenderError
= -- | The provider, the capability it cannot express, and why.
UnsupportedCapability !AgentProvider !AgentCapability !Text
| -- | The provider cannot honor a tool allow-list, and why.
UnsupportedToolRestriction !AgentProvider !Text
| -- | The general case: this provider cannot honor the requested
-- safety policy, and why. It carries no capability, so it also
-- serves surfaces whose safety vocabulary has no capability
-- profile — notably the interactive launchers, which share this
-- refusal type rather than growing a parallel one.
SafetyNotExpressible !AgentProvider !Text
| -- | The provider the renderer implements, then the provider the
-- request named. Each vendor renderer is a separate function in a
-- separate package, so nothing in the type system stops a caller
-- from handing a Codex request to the Claude renderer; without
-- this constructor the renderer's only options would be to
-- silently render the wrong provider's flags or to throw. The
-- order matters: reversing the pair names the wrong culprit.
ProviderMismatch !AgentProvider !AgentProvider
| -- | The request exceeded the operator's policy ceiling.
CeilingRejected ![CeilingViolation]
deriving stock (Eq, Show, Generic)
renderAgentRenderError :: AgentRenderError -> Text
renderAgentRenderError (UnsupportedCapability p cap why) =
renderAgentProvider p
<> " cannot express the requested capability "
<> renderAgentCapability cap
<> ": "
<> why
renderAgentRenderError (UnsupportedToolRestriction p why) =
renderAgentProvider p
<> " cannot express the requested tool restriction: "
<> why
renderAgentRenderError (SafetyNotExpressible p why) =
renderAgentProvider p
<> " cannot honor the requested safety policy: "
<> why
renderAgentRenderError (ProviderMismatch renderer requested) =
"the "
<> renderAgentProvider renderer
<> " renderer cannot render a request for provider "
<> renderAgentProvider requested
renderAgentRenderError (CeilingRejected violations) =
"the request exceeds the permitted policy ceiling: "
<> Text.intercalate "; " (map renderCeilingViolation violations)
-- | A failure raised while spawning the child process or waiting for
-- it.
--
-- There is deliberately no constructor for \"the process exited
-- non-zero\". That is a normal outcome and lives in 'AgentRunResult':
-- a coding agent that fails its task and exits 1 has still run.
data AgentRunFailure
= -- | The executable that could not be started, and the operating
-- system's message. The pair is what distinguishes \"the tool is
-- not installed\" from \"the tool is installed but the working
-- directory does not exist\".
SpawnFailed !FilePath !Text
| -- | The run exceeded this limit and was terminated.
RunTimedOut !NominalDiffTime
| -- | Every variable named in the request's 'envPassthrough' that is
-- unset or empty, checked as a group so an operator sees all of
-- them at once.
MissingEnvironment ![Text]
| -- | The working directory does not exist or is not a directory.
WorkingDirMissing !FilePath
| -- | The run produced output the caller could not interpret.
OutputMalformed !Text
| -- | The caller required evidence this configuration cannot produce,
-- so nothing was started. Carries one rendered explanation per
-- reason, from
-- 'Baikai.Evidence.Build.renderEvidenceRefusal'.
--
-- Structural rather than predictive: it fires when the requirement
-- is /impossible/ here, never when it merely might not be met. A run
-- that could have reported what the caller needed and did not says
-- so in its own record's @strength@; refusing it after the fact
-- would destroy a report of work that actually happened.
EvidenceRefused ![Text]
deriving stock (Eq, Show, Generic)
renderAgentRunFailure :: AgentRunFailure -> Text
renderAgentRunFailure (SpawnFailed path message) =
"could not start " <> Text.pack path <> ": " <> message
renderAgentRunFailure (RunTimedOut limit) =
"the run exceeded its timeout of " <> Text.pack (show limit)
renderAgentRunFailure (MissingEnvironment names) =
"required environment variables are unset or empty: "
<> Text.intercalate ", " names
renderAgentRunFailure (WorkingDirMissing path) =
"the working directory does not exist or is not a directory: "
<> Text.pack path
renderAgentRunFailure (OutputMalformed why) =
"the run produced malformed output: " <> why
renderAgentRunFailure (EvidenceRefused reasons) =
"refused before starting, because this run cannot produce the evidence it \
\required: "
<> Text.intercalate "; " reasons