Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENT_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ narrower target rules.
| Discover workflow inputs: `schema <target>` | JSON Schema for authored inputs | May load author code to resolve a target. |
| Validate: `plan <target> ... --check --json` | Expanded graph including problems; nonzero validation status | No FX paid calls; may import code, invoke builders, execute local `at: plan` nodes and write cache results. `--json` alone does not make plan problems a failing exit status. An image step's own request is checked against its route while planning (references need `image_input`, a `WxH` size `exact_size`, a transparent background `alpha`, and pictures within the route's `max_input_pictures`), so an agent learns at $0 that a route cannot take it. |
| Inspect planned work: `expand`, `identity`, `price <target> ...` | JSON graph, instance identities, or phased estimates | Planning conditions apply. Unresolved work and estimates are not proof of final work or actual billing. |
| Diagnose: `doctor [target]` | Text prerequisite, tool, route and key-presence diagnostics. The Python line says whether the node host's interpreter is ready, `too old` (FX needs Python 3.11+), has `no grida`, is `NOT FOUND`, or `cannot start`. The Node line after it says the same of the TypeScript node host's Node: ready (with its `@grida/fx` version), `too old` (FX needs Node 24+), `not Node.js 24+` (Bun, Deno), `no @grida/fx`, `NOT FOUND`, or `cannot start`. For that line, doctor starts the host only for a project with a `package.json` (the workflow's or the planning project), since its program may come from an `@grida/fx` above the project; without one, the line gives what Node answered, `(Node <version>)`, or the Node alone. With a target, `uses` lines come first: each route its paid steps use (groups and used workflows included), whether it is servable, and how many steps use it | Let FX inspect credential configuration; agents must not read secret values. Missing keys alone do not make its exit status fail; a route the target uses that cannot be served does (exit 1), so `doctor <target>` answers "can this run live?" without matching routes by hand. Needs no inputs, and takes no `--routes`: a project that plans with extra route files lists their routes as `not in the catalog`. It does not make provider calls and is not a provider health check. CLI-only: an SDK caller sees a Python older than 3.11, or a Node older than 24, as the engine's refusal sentence. Neither line changes the exit status. |
| Diagnose: `doctor [target]` | Text prerequisite, tool, route and key-presence diagnostics. The Python line says whether the node host's interpreter is ready, `too old` (FX needs Python 3.11+), has `no grida`, is `NOT FOUND`, or `cannot start`. The Node line after it says the same of the TypeScript node host's Node: ready (with its `@grida/fx` version), `too old` (FX needs Node 24+), `not Node.js 24+` (Bun, Deno), `no @grida/fx`, `NOT FOUND`, or `cannot start`. For that line, doctor starts the host only for a project with a `package.json` (the workflow's or the planning project), since its program may come from an `@grida/fx` above the project; without one, the line gives what Node answered, `(Node <version>)`, or the Node alone. A `program` line per installed-program provider names the Codex or Grok Build a live run would start, where it was found and, for Codex, its version; a Codex older than 0.160.1 (or naming none) makes its routes `too old` (`no version`), as a live run refuses it. With a target, `uses` lines come first: each route its paid steps use (groups and used workflows included), whether it is servable, and how many steps use it | Let FX inspect credential configuration; agents must not read secret values. Missing keys alone do not make its exit status fail; a route the target uses that cannot be served does (exit 1), so `doctor <target>` answers "can this run live?" without matching routes by hand. Needs no inputs, and takes no `--routes`: a project that plans with extra route files lists their routes as `not in the catalog`. It does not make provider calls and is not a provider health check. CLI-only: an SDK caller sees a Python older than 3.11, or a Node older than 24, as the engine's refusal sentence. Neither line changes the exit status. |
| Execute: `run <target> ... [--name NAME \| --run <folder>]` | Foreground execution, an early run URL when inspection is available, and final summary | Local code needs no `--live`; paid calls need admission and a ceiling, except cached answers and runs whose every call is on the user's own Codex or Grok Build (`imagegen@codex`, `imagine@grok`, which need no ceiling but spend the user's plan, so still need their go). Choose a name or explicit folder when supervising asynchronously. `--name` refuses collisions; neither naming nor a fresh record forces fresh generation. Project run pages survive completion through the separately started service. `--open` requests browser launch; `--standalone` owns a temporary viewer instead; `--no-view` skips integration and conflicts with `--open`. |
| Answer paid calls offline: `run <target> --stand-in FILE#FUNCTION`; SDK `run(…, stand_in=…)` / `run(…, { standIn })` | A run whose paid calls the cache cannot answer are answered by a test's function, at $0 | Never live and runs every phase: `--live` and `--yes-up-to` are usage errors. The file is `.py`, `.ts`, `.mts`, `.js` or `.mjs`, hosted by its language's stand-in host; the SDKs serve the function in the caller's process. Answers pass a provider's checks; what the run stores is kept apart from the project's store, and it resumes only as a stand-in run. It shows what a workflow does with an answer, not how a provider behaves. |
| Observe: `observe <run> --snapshot`, `observe <run> --after <cursor>`, `project <run>`, `inspect <run> --json` | Consistent snapshot, bounded event batches, state projection or summary | Read recorded data without executing workflow code. A step queued for its route's slot is `waiting` (with `waiting_for`), not `running`. Use a precise folder or `inspect <workflow-id>/<name>`; `inspect <workflow-id>` chooses the newest-created run, including failures, and refuses ambiguous recorded sources. Keep the emitted folder for observation commands. |
Expand Down
77 changes: 64 additions & 13 deletions crates/grida-fx-providers/src/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
//! pictures (`image`, then `references`), and each picture's file: carried, not empty, and
//! PNG, JPEG or WebP;
//! 4. the runtime's Codex, started if need be ([`runtime`]): no program ([`NOT_INSTALLED`],
//! [`NETWORK_OFF`]), MCP servers that cannot be listed, or a sign-in other than ChatGPT is
//! `Sent::Refused`, for this call and the invocation's others;
//! [`NETWORK_OFF`]), one older than [`MIN_VERSION`] or that names no version, MCP servers that
//! cannot be listed, or a sign-in other than ChatGPT is `Sent::Refused`, for this call and the
//! invocation's others;
//! 5. a scratch folder of the call's own holding copies of its pictures (`picture-01.png`, ...),
//! and a thread in it (read-only sandbox, no approvals, not persisted; a model provider other
//! than OpenAI is refused), then `turn/start` with [`instructions`], which name the pictures
Expand All @@ -40,7 +41,7 @@ use crate::adapter::{
Adapter, Answer, Billing, CallRequest, RequestAdapter, RouteRef, Sent, Usage,
};
use crate::capabilities;
use crate::installed::{self, Picture, failed};
use crate::installed::{self, Picture, Program, Version, failed};
use crate::registry::Adapters;
use crate::setup::Setup;
use crate::wire;
Expand Down Expand Up @@ -81,6 +82,9 @@ pub const PROGRAM_VARIABLE: &str = "GRIDA_FX_TOOL_CODEX";
pub const MACOS_APP_PROGRAM: &str =
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex";

/// The oldest Codex this behaviour was verified on.
pub const MIN_VERSION: &str = "0.160.1";

/// The refusal when no program was found.
pub const NOT_INSTALLED: &str = "Codex is not installed: install it with npm install -g \
@openai/codex and sign in with codex login, or set \
Expand Down Expand Up @@ -134,8 +138,8 @@ const SETTINGS: [&str; 17] = [
"web_search=\"disabled\"",
];

/// The deadline of listing the user's MCP servers.
const LIST_DEADLINE: Duration = Duration::from_secs(30);
/// The deadline of asking Codex its version, and of listing the user's MCP servers.
const ASK_DEADLINE: Duration = Duration::from_secs(30);

/// How long an answered turn has to end after it is interrupted, for its token usage.
const SETTLE: Duration = Duration::from_secs(3);
Expand Down Expand Up @@ -251,9 +255,50 @@ pub fn register(adapters: &mut Adapters, setup: &Setup) {
/// nothing else when it names no executable file;
/// - else the first `codex` in an absolute `PATH` entry (empty and relative entries are skipped,
/// so a project's own folder never supplies it);
/// - else `app` when it is executable.
pub fn find_program(env: &dyn Fn(&str) -> Option<String>, app: &Path) -> Option<PathBuf> {
installed::find_program(env, PROGRAM_VARIABLE, "codex", Some(app.to_path_buf()))
/// - else `app` when it is executable (origin `the ChatGPT app`).
pub fn find_program(env: &dyn Fn(&str) -> Option<String>, app: &Path) -> Option<Program> {
let fallback = (app.to_path_buf(), "the ChatGPT app");
installed::find_program(env, PROGRAM_VARIABLE, "codex", Some(fallback))
}

/// Step 4: Codex's version, as `codex --version` names it (`codex-cli 0.160.1`), against
/// [`MIN_VERSION`]; the refusal when it cannot be started. Codex runs in `folder`.
pub fn version<'a>(program: &'a Path, folder: &'a Path) -> BoxFuture<'a, Result<Version, String>> {
Box::pin(async move {
match installed::ask_version(program, folder, &ALSO_WITHHELD, ASK_DEADLINE).await {
Ok(named) => Ok(Version::of(named.as_deref(), MIN_VERSION)),
Err(error) => Err(not_started(&error)),
}
})
}

/// Step 4: the refusal of a Codex that [`version`] found older than [`MIN_VERSION`], or that
/// names no version. It says where the program was found, never its path.
pub(super) fn check_version(program: &Program, version: &Version) -> Result<(), String> {
let from = program.origin;
match version {
Version::Supported(_) => Ok(()),
Version::Older { version, minimum } => Err(format!(
"Codex {version} (from {from}) is older than {minimum}: update it, or set \
{PROGRAM_VARIABLE} to a newer one"
)),
Version::Unnamed => Err(format!(
"Codex (from {from}) did not name its version: update it to {MIN_VERSION} or later, \
or set {PROGRAM_VARIABLE} to a newer one"
)),
}
}

/// The refusal when Codex cannot be started.
pub(super) fn not_started(error: &std::io::Error) -> String {
if error.kind() == std::io::ErrorKind::NotFound {
NOT_INSTALLED.into()
} else {
format!(
"{LABEL} cannot start codex: {}",
grida_fx_core::error::io_reason(error)
)
}
}

/// Every variable Codex never inherits.
Expand Down Expand Up @@ -372,7 +417,7 @@ pub(super) async fn mcp_servers(program: &Path, folder: &Path) -> Result<Vec<Str
args.extend(["mcp", "list", "--json"].map(String::from));
let mut command = command(program, &args, folder);
command.stdin(Stdio::null());
let output = match tokio::time::timeout(LIST_DEADLINE, command.output()).await {
let output = match tokio::time::timeout(ASK_DEADLINE, command.output()).await {
Ok(Ok(output)) if output.status.success() => output,
_ => return Err(refused()),
};
Expand Down Expand Up @@ -826,9 +871,12 @@ mod tests {
(PROGRAM_VARIABLE, shown(&app)),
("PATH", path.clone()),
]);
let find_program = |env: &dyn Fn(&str) -> Option<String>, app: &Path| {
find_program(env, app).map(|program| (program.path, program.origin))
};
assert_eq!(
find_program(&named, Path::new("/missing")),
Some(app.clone())
Some((app.clone(), PROGRAM_VARIABLE))
);
let missing = env(vec![
(PROGRAM_VARIABLE, "/nonexistent/codex".into()),
Expand All @@ -840,15 +888,18 @@ mod tests {
// A relative name becomes absolute.
let relative = env(vec![(PROGRAM_VARIABLE, "some/codex".into())]);
let found = find_program(&relative, &app);
assert!(found.is_none_or(|p| p.is_absolute()));
assert!(found.is_none_or(|(p, _)| p.is_absolute()));
// Then PATH, absolute entries only; then the app.
let blank = env(vec![(PROGRAM_VARIABLE, " ".into()), ("PATH", path.clone())]);
assert_eq!(find_program(&blank, &app), Some(on_path));
assert_eq!(find_program(&blank, &app), Some((on_path, "PATH")));
let relative_path = env(vec![(
"PATH",
format!("bin{}", if cfg!(windows) { ";" } else { ":" }),
)]);
assert_eq!(find_program(&relative_path, &app), Some(app.clone()));
assert_eq!(
find_program(&relative_path, &app),
Some((app.clone(), "the ChatGPT app"))
);
assert_eq!(find_program(&env(vec![]), &plain), None);
}

Expand Down
47 changes: 21 additions & 26 deletions crates/grida-fx-providers/src/codex/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
//! invocation starts on its first Codex call and keeps for the others, and shuts down with the run
//! ([`crate::adapter::Runtime`]).
//!
//! Starting it, once: list the user's MCP servers to turn them off, start Codex in a process group
//! of its own ([`crate::process_group`]), `initialize`, then `account/read`; a Codex that is not signed
//! in with ChatGPT is refused for the rest of the invocation. Two tasks then serve the
//! connection: a writer, which owns Codex's stdin and writes the lines it is sent, so even a
//! dropped call can ask for its turn to be interrupted without waiting; and a reader, which hands
//! each response to the request waiting for it and each notification to the thread it names, and
//! declines every request Codex sends (an approval, a question). A call has a thread of its own
//! Starting it, once: check its version ([`super::version`]), list the user's MCP servers to turn
//! them off, start Codex in a process group of its own ([`crate::process_group`]), `initialize`,
//! then `account/read`; a Codex that is too old or not signed in with ChatGPT is refused for the
//! rest of the invocation. Two tasks then serve the connection: a writer, which owns Codex's
//! stdin and writes the lines it is sent, so even a dropped call can ask for its turn to be
//! interrupted without waiting; and a reader, which hands each response to the request waiting
//! for it and each notification to the thread it names, and declines every request Codex sends
//! (an approval, a question). A call has a thread of its own
//! ([`Connection::start_thread`]); a response to `thread/start` opens the thread's notifications
//! before the reader reads on, so none is missed. A response to `turn/start` whose call is gone
//! (dropped before Codex named its turn) has the reader interrupt the turn it names
Expand All @@ -22,11 +23,10 @@ use super::Stop;
use super::protocol::{self, Message};
use crate::BoxFuture;
use crate::adapter::Runtime;
use crate::installed::{locked, read_line};
use crate::installed::{Program, locked, read_line};
use crate::process_group::Group;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncWriteExt, BufReader};
Expand Down Expand Up @@ -308,15 +308,15 @@ enum State {
/// The runtime (module doc): no Codex runs until a call needs one.
pub struct CodexRuntime {
/// The program, or the refusal of every call.
program: Result<PathBuf, String>,
program: Result<Program, String>,
state: tokio::sync::Mutex<State>,
/// The user's usage limit, once a call found it reached: `Some(resets_at)`, the unix time it
/// resets when Codex gave it. No call starts while it holds.
limit: Mutex<Option<Option<i64>>>,
}

impl CodexRuntime {
pub fn new(program: Result<PathBuf, String>) -> CodexRuntime {
pub fn new(program: Result<Program, String>) -> CodexRuntime {
CodexRuntime {
program,
state: tokio::sync::Mutex::new(State::Idle),
Expand Down Expand Up @@ -392,30 +392,25 @@ impl Runtime for CodexRuntime {
}
}

/// Starts a Codex and checks its sign-in (module doc).
async fn open(program: &Path) -> Result<Connection, Stop> {
/// Starts a Codex and checks its version and sign-in (module doc).
async fn open(program: &Program) -> Result<Connection, Stop> {
let folder = tempfile::Builder::new()
.prefix("grida-fx-codex-")
.tempdir()
.map_err(|error| Stop::Failed(grida_fx_core::error::io_reason(&error)))?;
let version = super::version(&program.path, folder.path())
.await
.map_err(Stop::Refused)?;
super::check_version(program, &version).map_err(Stop::Refused)?;
let program = program.path.as_path();
let servers = super::mcp_servers(program, folder.path())
.await
.map_err(Stop::Refused)?;
let mut args = vec!["app-server".to_string()];
args.extend(super::settings(servers));
let mut child = match super::command(program, &args, folder.path()).spawn() {
Ok(child) => child,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(Stop::Refused(super::NOT_INSTALLED.into()));
}
Err(error) => {
return Err(Stop::Refused(format!(
"{} cannot start codex: {}",
super::LABEL,
grida_fx_core::error::io_reason(&error)
)));
}
};
let mut child = super::command(program, &args, folder.path())
.spawn()
.map_err(|error| Stop::Refused(super::not_started(&error)))?;
let Some(group) = Group::enlist(&mut child) else {
return Err(Stop::Failed("the command is stopping".into()));
};
Expand Down
Loading
Loading