Skip to main content

objectiveai_cli/
error.rs

1#[derive(Debug, thiserror::Error)]
2pub enum Error {
3    #[error("{0}")]
4    Filesystem(#[from] crate::filesystem::Error),
5    #[error("{}", format_http_error(.0))]
6    Http(#[from] objectiveai_sdk::HttpError),
7    #[error("{0}")]
8    ResponseError(objectiveai_sdk::error::ResponseError),
9    #[error("{0} source is not supported for function-profile pairs")]
10    PairsSourceNotSupported(&'static str),
11    #[error("authorization is global only")]
12    AuthorizationGlobalOnly,
13    #[error("{0}")]
14    MissingArgs(&'static str),
15    #[error("invalid path: {0}")]
16    PathParse(String),
17    #[error("python wasm runtime error: {0}")]
18    PythonWasm(String),
19    #[error("podman: {0}")]
20    Podman(String),
21    #[error("failed to read python file {0}: {1}")]
22    PythonFileRead(std::path::PathBuf, std::io::Error),
23    #[error("failed to read prompt file {0}: {1}")]
24    PromptFileRead(std::path::PathBuf, std::io::Error),
25    #[error("failed to read JSON file {0}: {1}")]
26    JsonFileRead(std::path::PathBuf, std::io::Error),
27    #[error("python exception:\n{0}")]
28    PythonException(String),
29    #[error("python script produced no output")]
30    PythonNoOutput,
31    #[error("python output deserialization failed: {0}")]
32    PythonDeserialize(serde_path_to_error::Error<serde_json::Error>),
33    #[error("internal error: python harness output is malformed: {0}")]
34    PythonHarnessBroken(String),
35    #[error("inline JSON deserialization failed: {0}")]
36    InlineDeserialize(serde_path_to_error::Error<serde_json::Error>),
37    #[error("inline JSON conversion failed: {0}")]
38    InlineJson(#[from] serde_json::Error),
39    #[error("stream ended without producing any chunks")]
40    EmptyStream,
41    #[error("config set forbidden by server configuration")]
42    ConfigSetForbidden,
43    #[error("log writer task panicked or was cancelled")]
44    WriterPanic,
45    #[error("subscribe timed out")]
46    LogSubscribeTimedOut,
47    #[error("plugin not found: {0}")]
48    PluginNotFound(String),
49    #[error("failed to spawn plugin: {0}")]
50    PluginSpawn(std::io::Error),
51    #[error("failed to read plugin output: {0}")]
52    PluginRead(std::io::Error),
53    #[error("plugin exited with non-zero status: {0}")]
54    PluginExit(i32),
55    #[error("plugins may not invoke `{0}` commands")]
56    PluginCommandForbidden(&'static str),
57    #[error("tool not found: {0}")]
58    ToolNotFound(String),
59    #[error("failed to spawn tool: {0}")]
60    ToolSpawn(std::io::Error),
61    #[error("failed to read tool output: {0}")]
62    ToolRead(std::io::Error),
63    #[error("tool exited with non-zero status: {0}")]
64    ToolExit(i32),
65    #[error(
66        "{kind} {owner}/{repository} (commit {commit_sha}, version {version}) is not in the install whitelist; pass --allow-untrusted to install anyway"
67    )]
68    NotWhitelisted {
69        kind: &'static str,
70        owner: String,
71        repository: String,
72        commit_sha: String,
73        version: String,
74    },
75    #[error("whitelist regex error: {0}")]
76    WhitelistRegex(regex::Error),
77    #[error(
78        "daemon address unavailable: the daemon could not be spawned or its lock could not be read"
79    )]
80    DaemonAddressUnavailable,
81    #[error("updater: {0}")]
82    Updater(String),
83    #[error("instance runner: {0}")]
84    Instance(String),
85    #[error("not found: {0}")]
86    NotFound(String),
87    #[error("{0} remote is not supported for this command")]
88    RemoteNotSupported(&'static str),
89    #[error("{0}")]
90    ClapParse(#[from] clap::Error),
91    #[error("argument parse error at `{}`: {}", .0.field, .0.source)]
92    FromArgs(#[from] objectiveai_sdk::cli::command::FromArgsError),
93    #[error("{name} exited before publishing its lock ({status}); stdout: {stdout}; stderr: {stderr}")]
94    SpawnExitedBeforePublishing {
95        name: String,
96        status: std::process::ExitStatus,
97        stdout: String,
98        stderr: String,
99    },
100    #[error("lockfile {key}: {source}")]
101    Lockfile { key: String, source: std::io::Error },
102    #[error("spawn {0}: {1}")]
103    Spawn(String, std::io::Error),
104    #[error(
105        "no prior agent_completion_request for agent {agent_instance_hierarchy:?}; spawn the agent first with `agents spawn`"
106    )]
107    AgentNoPriorRequest { agent_instance_hierarchy: String },
108    #[error(
109        "agent {agent_instance_hierarchy:?} has no continuations available across {request_count} prior request(s); the most recent turn may still be streaming, or none have finished. Cannot fall back without a continuation."
110    )]
111    AgentNoContinuation {
112        agent_instance_hierarchy: String,
113        request_count: usize,
114    },
115    #[error(
116        "tag {tag:?} exists but the agent has not been spawned yet (tag_group_id={tag_group_id}, parent_agent_instance_hierarchy={parent_agent_instance_hierarchy:?})"
117    )]
118    TagGrouped {
119        tag: String,
120        tag_group_id: i64,
121        parent_agent_instance_hierarchy: String,
122    },
123    #[error("tag {0:?} is not registered")]
124    TagNotFound(String),
125    #[error(
126        "agent instance {agent_instance_hierarchy:?} is already active (its lock is held by a live process)"
127    )]
128    AgentInstanceActive { agent_instance_hierarchy: String },
129    #[error("agent tag {tag:?} is already being spawned (its lock is held by a live process)")]
130    AgentTagActive { tag: String },
131    #[error(
132        "cannot apply tag {tag:?} while an agent holding it is active (its tag lock is held by a live process)"
133    )]
134    TagApplyAgentActive { tag: String },
135    #[error("cannot enqueue against an agent ref; enqueue targets an instance or a tag")]
136    EnqueueRefTarget,
137    #[error("cannot wait on an agent ref; wait targets an instance or a tag")]
138    WaitRefTarget,
139    #[error("cannot attach/detach a laboratory to an agent ref; target an instance or a tag")]
140    LaboratoryRefTarget,
141    #[error("laboratory {laboratory_id:?} is already attached to this agent")]
142    LaboratoryAlreadyAttached { laboratory_id: String },
143    #[error("laboratory {laboratory_id:?} is not attached to this agent")]
144    LaboratoryNotAttached { laboratory_id: String },
145    #[error(
146        "FATAL: tag {tag:?} lock was released without its GROUPED->BOUND upgrade; the spawn flow's upgrade-before-release invariant is broken"
147    )]
148    TagLockDroppedWithoutUpgrade { tag: String },
149    #[error(
150        "queued message {id} was sent by {sender_agent_instance_hierarchy:?}; it can only be deleted by the sender or a parent of the sender (caller is {caller_agent_instance_hierarchy:?})"
151    )]
152    QueueDeleteUnauthorized {
153        id: i64,
154        sender_agent_instance_hierarchy: String,
155        caller_agent_instance_hierarchy: String,
156    },
157    #[error(
158        "a schedule named {name:?} already exists for {agent_instance_hierarchy:?}; pass --overwrite to replace it"
159    )]
160    ScheduleAlreadyExists {
161        name: String,
162        agent_instance_hierarchy: String,
163    },
164    #[error("db: {0}")]
165    Db(#[from] crate::db::Error),
166    /// Endpoint exists in the command tree but the underlying
167    /// implementation hasn't landed yet — typically because it depends
168    /// on the postgres-backed `logs.*` reader that's still in flight.
169    #[error("not implemented: {0}")]
170    NotImplemented(&'static str),
171    #[error("invalid query: {0}")]
172    InvalidQuery(String),
173    #[error("query exceeded timeout")]
174    QueryTimeout,
175    #[error("query attempted a write in a read-only context")]
176    QueryReadOnlyViolation,
177    /// The executor's whole-stream deadline elapsed. Yielded as the
178    /// stream's final item; the stream never outlives the timeout.
179    #[error("timed out after {timeout_seconds}s")]
180    Timeout { timeout_seconds: u64 },
181    /// The executor's running token tally over the serialized output
182    /// exceeded the request's `max_tokens` budget. Yielded as the
183    /// stream's final item in place of the item that pushed it over.
184    #[error("response exceeded token budget — actual {actual} tokens, limit {limit}")]
185    TokenBudgetExceeded { limit: u64, actual: u64 },
186}
187
188impl Error {
189    /// JSON value to use when serializing this error in user-facing
190    /// output. For `ResponseError` this is the inner error serialized
191    /// as a structured object; for everything else it's a string built
192    /// from the `Display` impl.
193    pub fn output_message(&self) -> serde_json::Value {
194        match self {
195            Error::ResponseError(re) => {
196                serde_json::to_value(re).unwrap_or_else(|_| self.to_string().into())
197            }
198            _ => self.to_string().into(),
199        }
200    }
201}
202
203fn http_is_connect_failure(err: &objectiveai_sdk::HttpError) -> bool {
204    use objectiveai_sdk::HttpError as H;
205    let reqwest_err = match err {
206        H::StreamError(reqwest_eventsource::Error::Transport(e)) => e,
207        H::RequestError(e) | H::HttpError(e) => e,
208        _ => return false,
209    };
210    reqwest_err.is_connect() || reqwest_err.is_timeout()
211}
212
213fn format_http_error(err: &objectiveai_sdk::HttpError) -> String {
214    if http_is_connect_failure(err) {
215        format!(
216            "{err}\n\nhint: this looks like a connection failure to the resolved API \
217address. the cli auto-spawns a local objectiveai-api when no address is \
218configured; if you set `api.address`, verify it \
219(`objectiveai api config address get --final`) or unset it to use the \
220local server. to (re)start the local server: `objectiveai api spawn`"
221        )
222    } else {
223        err.to_string()
224    }
225}