diff --git a/README.md b/README.md index 42d4d0a4..7411f899 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ You own the endpoints, the tools, the skills, and the context. ## Features -- **Small by default.** Seven built-in tools (`list_dir`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `bash`) and a short system prompt. `--profile minimal` freezes only `read_file`, `write_file`, `edit_file`, and `bash` behind a one-line prompt, with no project instructions, layout map, skills, memory, hooks, or extension tools: the capability set of the two-tool scaffold most coding benchmarks use, for measuring a model with the least harness help. Old tool output is dropped before your task is, and dropped context is summarized by your own model into a compact note (heuristic digest as fallback) whose address points at the lossless archive of everything dropped — compaction is a bounded view over a record you can always read back. +- **Small by default.** Seven built-in tools (`list_dir`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `bash`) and a short system prompt. Old tool output is dropped before your task is, and dropped context is summarized by your own model into a compact note (heuristic digest as fallback) whose address points at the lossless archive of everything dropped — compaction is a bounded view over a record you can always read back. - **Your model, your server.** One `base_url`, or several named endpoints in `providers.json` switched with `/model`. Works with local servers (Ollama, LM Studio, vLLM, llama.cpp), cloud gateways (OpenRouter and similar), and private proxies. - **Trust before execution.** An exact canonical project root must be trusted before any agent turn or project behavior starts. Interactive use asks once; headless and stdio runs fail closed until explicitly started with `--trust-project`. - **Approvals by default.** `write_file`, `edit_file`, and `bash` wait for approval in `ask` mode. Use `auto` for unattended runs or `readonly` to block mutating tools. Approvals and permissions decide whether Open Max dispatches a tool call; they are not OS isolation. diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index 03e4ba9e..826e8a56 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -47,7 +47,7 @@ use crate::config::{ApprovalMode, Settings}; use crate::hooks::{Hooks, PreToolResult}; use crate::permissions::{PermissionDecision, Permissions, TurnPermissions}; use crate::prompt::{system_prompt_with_breakdown, PromptBreakdown}; -use crate::registry::{Profile, Registry}; +use crate::registry::Registry; use crate::sessions; use crate::state::{CancelToken, Core, SessionData}; use crate::tools; @@ -411,65 +411,6 @@ async fn report_schemas_over_budget( } } -/// Record the profile a session that has no transcript yet will freeze -/// under, before its first turn hydrates it. The manifest is the record: -/// hydration honors a manifest saved ahead of the first message, so a -/// frontend calls this right after `sessions::create`. `Full` is what -/// hydration freezes on its own and is left to it, so the memory scan that -/// grounds a full prompt stays the single scan its freeze already makes. -/// A session that already has a manifest keeps it: a profile is fixed for -/// the session's life. -/// -/// Fails closed. `save_manifest` reports a failed write on the event -/// channel and returns, and the manifest is the only record of the profile, -/// so the write is verified by reading it back: a session that cannot -/// record `minimal` must not start, because hydration would freeze the full -/// profile and a measurement run would silently measure the wrong harness. -pub fn freeze_new_session(core: &Core, session_id: &str, project_root: &Path, profile: Profile) -> Result<(), String> { - if profile == Profile::Full { - return Ok(()); - } - if let Some(existing) = sessions::load_manifest(core, session_id) { - return if existing.profile == profile { - Ok(()) - } else { - Err(format!( - "session {session_id} already froze the {} profile; a profile is fixed for a session's life", - existing.profile.as_str() - )) - }; - } - let registry = Registry::build_for(profile, &core.data_dir, project_root); - sessions::save_manifest(core, session_id, ®istry.to_manifest()); - match sessions::load_manifest(core, session_id) { - Some(manifest) if manifest.profile == profile => Ok(()), - _ => Err(format!( - "could not record the {} profile for session {session_id}; the session was not started", - profile.as_str() - )), - } -} - -/// The profile a session runs under: its in-memory registry once hydrated, -/// its manifest before that. A session with neither is a full one, which is -/// what hydration will freeze. -async fn session_profile(core: &Core, session_id: &str) -> Profile { - if let Some(data) = core.sessions.lock().await.get(session_id) { - return data.registry.profile; - } - sessions::load_manifest(core, session_id).map(|m| m.profile).unwrap_or_default() -} - -/// The hooks one turn of `session_id` runs. A minimal session runs none: -/// hooks are project-specific harness behavior, and the profile's promise is -/// that the same run behaves the same in every checkout. -async fn hooks_for_session(core: &Core, session_id: &str, project_root: &Path, mode: ApprovalMode) -> Hooks { - match session_profile(core, session_id).await { - Profile::Minimal => Hooks::suppressed(), - Profile::Full => Hooks::discover_for_mode(project_root, &core.data_dir, mode), - } -} - fn build_session_data(core: &Arc, session_id: &str, project_root: &Path) -> Result { sessions::ensure_owned(core, session_id)?; Ok(if let Some(mut messages) = sessions::load_messages(core, session_id)? { @@ -1443,16 +1384,6 @@ pub async fn reload_session( let (prompt, breakdown) = system_prompt_with_breakdown(project_root, ®istry); ensure_session_hydrated(core, session_id, project_root).await?; - { - let sessions_map = core.sessions.lock().await; - if sessions_map.get(session_id).is_some_and(|d| d.registry.profile == Profile::Minimal) { - return Err( - "this session runs the minimal profile, which freezes no extension files; \ - start a session without --profile minimal to use them" - .into(), - ); - } - } let counts = (registry.tools.len(), registry.skills.len()); { @@ -1630,7 +1561,7 @@ async fn run_compact( }); } let client = ChatClient::from_endpoint(&endpoint); - let hooks = hooks_for_session(core, session_id, project_root, core.approval_mode(project_root)).await; + let hooks = Hooks::discover_for_mode(project_root, &core.data_dir, core.approval_mode(project_root)); let ctx = CompactionCtx { core, session_id, project_root, client: &client, hooks: &hooks, cancelled }; let mut spend = RequestSpend::new(settings.max_agent_tokens); let compacted_messages = match compact_messages(&ctx, guard.messages(), trigger, schema_tokens, true, &mut spend).await { @@ -2088,37 +2019,16 @@ async fn refreeze_if_extensions_changed( }; let files = std::mem::take(&mut snapshot.files); let disk_fp = snapshot.fingerprint(); - let (stale, unsynced, minimal) = { + let (stale, unsynced) = { let sessions_map = core.sessions.lock().await; match sessions_map.get(session_id) { Some(d) => ( !d.messages.is_empty() && d.registry.ext_fingerprint != disk_fp, !d.ledger_synced, - d.registry.profile == Profile::Minimal, ), - None => (false, false, false), + None => (false, false), } }; - if minimal && stale { - // The minimal profile freezes no extension surface, so a changed - // generation is never activated or narrated to the model. It is - // still recorded: the files changed while no turn ran, which makes - // the change external, and the fingerprint moves with it so the - // same generation is not recorded twice. The fingerprint moves - // before the record lands, the order the full profile keeps: a - // failed record is retried by the next turn-start sync, which files - // it as external, the one direction the ledger tolerates - // (settle_ledger). - record_generation_unactivated(core, session_id, &Arc::new(Registry::minimal_at(disk_fp))).await; - let (receipt, landed) = - settle_ledger(core, session_id, project_root, files, crate::ledger::Actor::External).await; - if !landed { - for message in receipt { - core.send_agent(session_id, AgentEvent::Error { message }); - } - } - return None; - } if !stale { if unsynced { // Nothing to activate, but the freeze read these files straight @@ -2191,18 +2101,6 @@ async fn refreeze_if_extensions_changed( None } -/// Pin a minimal session's registry to a new extension generation without -/// changing its tools or prompt: the profile has no surface to activate, but -/// the refreeze checks compare the fingerprint against disk, and the manifest -/// must carry the generation the ledger has now recorded. -async fn record_generation_unactivated(core: &Arc, session_id: &str, fresh: &Arc) { - let mut sessions_map = core.sessions.lock().await; - if let Some(data) = sessions_map.get_mut(session_id) { - data.registry = fresh.clone(); - sessions::save_manifest(core, session_id, &data.registry.to_manifest()); - } -} - /// Sync the ledger and describe the outcome for the refreeze receipt. A /// ledger failure never blocks activation, but it is reported in the receipt /// rather than swallowed. The flag says whether the sync actually landed: @@ -2326,25 +2224,6 @@ async fn refreeze_between_iterations( if snapshot.fingerprint() == registry.ext_fingerprint { return false; } - if registry.profile == Profile::Minimal { - // Recorded as this session's work, since its own mutating call - // produced the change, and never activated: the minimal profile has - // no extension surface (see refreeze_if_extensions_changed). Same - // order as the full profile: the fingerprint moves first, and a - // record that fails here is retried at the next turn start as - // external, which understates the agent and never the human. - let fresh = Arc::new(Registry::minimal_at(snapshot.fingerprint())); - record_generation_unactivated(core, session_id, &fresh).await; - *registry = fresh; - let (receipt, landed) = - settle_ledger(core, session_id, project_root, files, crate::ledger::Actor::Session).await; - if !landed { - for message in receipt { - core.send_agent(session_id, AgentEvent::Error { message }); - } - } - return false; - } let Ok(new_registry) = tokio::task::spawn_blocking(move || Registry::from_snapshot(snapshot)).await else { @@ -2502,7 +2381,7 @@ async fn refresh_approval_policy( let current = core.approval_mode(project_root); if current != *mode { *mode = current; - *hooks = hooks.rediscover_for_mode(project_root, &core.data_dir, current); + *hooks = Hooks::discover_for_mode(project_root, &core.data_dir, current); permissions.reload(Permissions::discover_for_mode(project_root, &core.data_dir, current)); report_hook_failures(core, session_id, hooks.notices()); } @@ -2541,7 +2420,7 @@ async fn run_loop( // ever enters the transcript. A blocked or cancelled submit is not a // started turn (no title write, no session_start, no turn_end). let mut policy_mode = core.approval_mode(project_root); - let mut hooks = hooks_for_session(core, session_id, project_root, policy_mode).await; + let mut hooks = Hooks::discover_for_mode(project_root, &core.data_dir, policy_mode); // A hook that exists but did not load says so every turn. Inert is a // policy the user wrote down and is not getting, so it must not be // something they only discover by running `openmax --check`. @@ -3486,7 +3365,7 @@ async fn run_loop( let hooks_now = crate::hooks::hooks_fingerprint(&core.data_dir, project_root); if hooks_now != hooks_seen { hooks_seen = hooks_now; - let discovered = hooks.rediscover_for_mode(project_root, &core.data_dir, core.approval_mode(project_root)); + let discovered = Hooks::discover_for_mode(project_root, &core.data_dir, core.approval_mode(project_root)); let inert: Vec = discovered .notices() .into_iter() @@ -5341,6 +5220,60 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// A manifest written by the build that briefly had a second session + /// shape (v4, with a shape field) must not resume into a hybrid: the + /// persisted one-line prompt beside the full tool set. The version gate + /// retires that shape wholesale. The manifest reads as absent, the + /// session falls back to built-ins, and the first turn-start check + /// refreezes prompt and manifest from disk into one consistent shape. + #[tokio::test] + async fn a_manifest_from_the_retired_shape_refreezes_into_one_shape() { + use crate::state::Core; + + let dir = std::env::temp_dir().join(format!("openmax-agent-{}", uuid::Uuid::new_v4())); + let (core, _rx) = Core::new(dir.clone()).unwrap(); + let project = dir.join("project"); + std::fs::create_dir_all(&project).unwrap(); + crate::trust::trust_project(&core.data_dir, &project).unwrap(); + let id = sessions::create(&core, project.display().to_string()).unwrap().id; + // The retired build's records: a version-4 manifest whose fingerprint + // matches disk (so nothing but the version gate can trigger a + // refreeze), and a transcript whose system line is the bare prompt. + let mut legacy = Registry::build(&core.data_dir, &project).to_manifest(); + legacy.version = 4; + sessions::save_manifest(&core, &id, &legacy); + let bare = "You are Open Max, a coding agent working in the current project. Tool paths are project-relative."; + let messages = vec![ + ChatMessage::system(bare), + ChatMessage::user("hi"), + ChatMessage::assistant(Some("hello".into()), None), + ]; + let mut persisted = 0usize; + assert!(sessions::save_messages(&core, &id, &messages, &mut persisted, true)); + + assert!(sessions::load_manifest(&core, &id).is_none(), "the retired shape reads as absent"); + let data = build_session_data(&core, &id, &project).unwrap(); + core.sessions.lock().await.insert(id.clone(), data); + assert!( + refreeze_if_extensions_changed(&core, &id, &project).await.is_some(), + "the first turn-start check refreezes from disk" + ); + { + let map = core.sessions.lock().await; + let data = map.get(&id).unwrap(); + let prompt = data.messages[0].content.as_deref().unwrap(); + assert_ne!(prompt, bare, "the bare prompt must not survive beside seven tools"); + assert!(prompt.contains("Rules:"), "{prompt}"); + assert_eq!(data.registry.tool_names().len(), tools::TOOL_NAMES.len()); + } + assert_eq!( + sessions::load_manifest(&core, &id).map(|m| m.version), + Some(crate::registry::MANIFEST_VERSION), + "the refreeze writes the current manifest shape" + ); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn build_session_data_honors_manifest_without_messages() { use crate::state::Core; @@ -5418,145 +5351,6 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } - /// The minimal profile is frozen ahead of hydration and fixed for the - /// session's life: the shell-plus-editor subset, the persona prompt, and - /// nothing from the project, however much sits on disk. An extension file - /// that appears between turns is recorded for the ledger and never - /// activated, so prompt and schemas stay byte-identical for the whole - /// session, a resume rebuilds the same shape from the manifest, and - /// /reload has nothing to offer. - #[tokio::test] - async fn a_minimal_session_freezes_the_subset_and_never_activates_extensions() { - use crate::state::Core; - - let dir = std::env::temp_dir().join(format!("openmax-agent-{}", uuid::Uuid::new_v4())); - let (core, _rx) = Core::new(dir.clone()).unwrap(); - let project = dir.join("project"); - std::fs::create_dir_all(project.join(".openmax/tools")).unwrap(); - std::fs::write(project.join("AGENTS.md"), "# Rules\nAlways run the tests.").unwrap(); - std::fs::write( - project.join(".openmax/tools/deploy.toml"), - "name = \"deploy\"\ndescription = \"ships it\"\ncommand = \"/bin/echo\"\nmutating = true\n", - ) - .unwrap(); - crate::trust::trust_project(&core.data_dir, &project).unwrap(); - let id = &sessions::create(&core, project.display().to_string()).unwrap().id; - - freeze_new_session(&core, id, &project, Profile::Minimal).unwrap(); - let prompt = { - let data = build_session_data(&core, id, &project).unwrap(); - assert_eq!(data.registry.profile, Profile::Minimal); - assert_eq!(data.registry.tool_names(), tools::MINIMAL_TOOL_NAMES); - assert!(data.registry.get("deploy").is_none(), "a tool on disk is not loaded"); - let prompt = data.messages[0].content.clone().unwrap(); - assert!(!prompt.contains("AGENTS.md") && !prompt.contains("openmax --spec"), "{prompt}"); - core.sessions.lock().await.insert(id.to_string(), data); - prompt - }; - // A transcript, so the turn-start check sees a live session. - core.sessions.lock().await.get_mut(id).unwrap().messages.push(ChatMessage::user("hi")); - - // Another tool file lands between turns. - std::fs::write( - project.join(".openmax/tools/lint.toml"), - "name = \"lint\"\ndescription = \"lints\"\ncommand = \"/bin/echo\"\nmutating = false\n", - ) - .unwrap(); - assert!( - refreeze_if_extensions_changed(&core, id, &project).await.is_none(), - "nothing is narrated to the model" - ); - { - let map = core.sessions.lock().await; - let data = map.get(id).unwrap(); - assert_eq!(data.registry.tool_names(), tools::MINIMAL_TOOL_NAMES); - assert_eq!(data.messages[0].content.as_deref(), Some(prompt.as_str()), "the prefix did not move"); - let disk = crate::registry::capture_extensions(&core.data_dir, &project).fingerprint(); - assert_eq!(data.registry.ext_fingerprint, disk, "the generation is remembered, not re-recorded"); - } - let manifest = sessions::load_manifest(&core, id).expect("manifest saved"); - assert_eq!(manifest.profile, Profile::Minimal); - assert!(manifest.external_tools.is_empty()); - let resumed = Registry::from_manifest(manifest); - assert_eq!(resumed.tool_names(), tools::MINIMAL_TOOL_NAMES); - assert!(reload_session(&core, id, &project).await.is_err(), "no extension surface to reload"); - - let _ = std::fs::remove_dir_all(dir); - } - - /// The minimal profile runs no project hooks: a gate that blocks every - /// prompt in a full session is inert in a minimal one, so the same run - /// behaves the same in every checkout, whatever policy the project - /// carries. Permission rules are not hooks and still apply. - #[tokio::test] - async fn a_minimal_session_runs_no_project_hooks() { - use crate::state::Core; - use std::io::Write as _; - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join(format!("openmax-agent-{}", uuid::Uuid::new_v4())); - let (core, mut rx) = Core::new(dir.clone()).unwrap(); - let project = dir.join("project"); - let hooks_dir = project.join(".openmax/hooks"); - std::fs::create_dir_all(&hooks_dir).unwrap(); - let script = project.join("gate.sh"); - { - let mut f = std::fs::File::create(&script).unwrap(); - f.write_all(b"#!/bin/sh\necho 'blocked by policy'; exit 1\n").unwrap(); - let mut perms = std::fs::metadata(&script).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script, perms).unwrap(); - } - std::fs::write( - hooks_dir.join("gate.toml"), - format!("event = \"user_prompt_submit\"\ncommand = \"{}\"\n", script.display()), - ) - .unwrap(); - approve_hook(&core, &project, &hooks_dir.join("gate.toml")); - crate::trust::trust_project(&core.data_dir, &project).unwrap(); - - let (base_url, requests) = counting_endpoint(STOP_SSE).await; - { - let mut s = core.settings.lock().unwrap(); - s.base_url = base_url; - s.model = "stub".into(); - s.context_tokens = Some(16384); - } - let id = sessions::create(&core, project.display().to_string()).unwrap().id; - freeze_new_session(&core, &id, &project, Profile::Minimal).unwrap(); - - start_turn(core.clone(), id.clone(), project.clone(), "should reach the model".into()).unwrap(); - let (stop, _) = drive_turn(&mut rx).await; - assert_ne!(stop, "blocked", "the project gate must not run under the minimal profile"); - assert_eq!(*requests.lock().unwrap(), 1, "the prompt reached the model"); - - let _ = std::fs::remove_dir_all(dir); - } - - /// The manifest is the only record of a session's profile. A session - /// that cannot record `minimal` must not start: hydration would freeze - /// the full profile, and a measurement run would silently measure the - /// wrong harness. - #[test] - fn a_minimal_session_that_cannot_record_its_profile_is_refused() { - use crate::state::Core; - - let dir = std::env::temp_dir().join(format!("openmax-agent-{}", uuid::Uuid::new_v4())); - let (core, _rx) = Core::new(dir.clone()).unwrap(); - let project = dir.join("project"); - std::fs::create_dir_all(&project).unwrap(); - let id = sessions::create(&core, project.display().to_string()).unwrap().id; - // A directory where the manifest file must land: the atomic write - // cannot replace it, so the profile cannot be recorded. - std::fs::create_dir_all(sessions::manifest_path(&core, &id)).unwrap(); - - let err = freeze_new_session(&core, &id, &project, Profile::Minimal).unwrap_err(); - assert!(err.contains("could not record the minimal profile"), "{err}"); - assert!(sessions::load_manifest(&core, &id).is_none()); - - let _ = std::fs::remove_dir_all(dir); - } - /// The create-then-use loop closes inside one turn: after a mutating call /// writes a tool file, the between-iterations check swaps the turn's own /// registry and system prompt, installs the generation into the session, diff --git a/crates/core/src/hooks.rs b/crates/core/src/hooks.rs index 576d4c5e..23d04023 100644 --- a/crates/core/src/hooks.rs +++ b/crates/core/src/hooks.rs @@ -196,12 +196,6 @@ pub struct Hooks { /// Hooks that exist but are not live, reported once per turn instead of /// vanishing: content no human approved, or a revoked observe hook. notices: Vec, - /// A set that runs nothing and stays that way: the minimal profile runs a - /// session without project hooks (they rewrite input, gate calls, and - /// inject guidance, which is project-specific harness behavior a - /// measurement must not carry), and every mid-turn re-discovery asks - /// this flag before it asks the disk. - suppressed: bool, } /// First stem wins: project dirs are listed before global, and that @@ -267,21 +261,6 @@ fn discover_in_dirs(dirs: &[PathBuf]) -> Hooks { } impl Hooks { - /// No hooks now and none on re-discovery; see the `suppressed` field. - pub fn suppressed() -> Self { - Self { suppressed: true, ..Self::default() } - } - - /// Discover again for a changed mode, unless this set is suppressed, in - /// which case the replacement is suppressed too. - pub fn rediscover_for_mode(&self, project_root: &Path, data_dir: &Path, mode: crate::config::ApprovalMode) -> Self { - if self.suppressed { - Self::suppressed() - } else { - Self::discover_for_mode(project_root, data_dir, mode) - } - } - /// Discover hooks under project `.openmax/hooks/` then global /// `~/.openmax/hooks/`. Project entries with the same file stem win. /// diff --git a/crates/core/src/prompt.rs b/crates/core/src/prompt.rs index 8fde1672..15a494c6 100644 --- a/crates/core/src/prompt.rs +++ b/crates/core/src/prompt.rs @@ -5,10 +5,6 @@ //! index, and the tools trailer. `system_prompt_with_breakdown` reports the //! size of each, which is what `/context` and `--spec usage` show. //! -//! Under the minimal profile (`registry::Profile::Minimal`) the prompt is the -//! persona line alone: every grounding section and the extension pointer are -//! skipped, so the prefix is byte-identical across projects. -//! //! Every variable-length component is capped in bytes (`AGENTS.md` 2,000, the //! layout map 1,200 at depth 2, skills 3,000) and says so when it truncates. //! Without caps the prompt would grow with the repository, and this text is @@ -47,13 +43,6 @@ const MAX_SKILL_NAME_SHOWN: usize = 64; const MEMORY_SECTION_HEADER: &str = "\n\nMemory (facts saved by earlier turns or sessions; read_file one before relying on it):\n"; -/// The whole system prompt of a minimal-profile session. No path: the shell -/// runs in the project root and every tool path is project-relative, so the -/// bytes are identical in every checkout of every project, which is what -/// makes a measurement under this profile comparable across machines. -pub const MINIMAL_PROMPT: &str = - "You are Open Max, a coding agent working in the current project. Tool paths are project-relative."; - /// The prompt text alone, for tests that only assert on its content. #[cfg(test)] fn system_prompt(project_root: &Path, registry: &Registry) -> String { @@ -124,16 +113,6 @@ impl PromptBreakdown { pub fn system_prompt_with_breakdown(project_root: &Path, registry: &Registry) -> (String, PromptBreakdown) { let root = project_root.to_string_lossy(); let mut breakdown = PromptBreakdown::default(); - if registry.profile == crate::registry::Profile::Minimal { - // The measurement prompt: the persona line and the one rule the - // tools need. No path, project instructions, layout map, skills, - // memory, or extension pointer, so the prefix is identical across - // checkouts and carries nothing the model could take as task help. - let prompt = MINIMAL_PROMPT.to_string(); - breakdown.components.push(("base rules".into(), prompt.len())); - breakdown.add_registry(registry, project_root); - return (prompt, breakdown); - } // Tool-specific guidance lives in each tool's schema description (which // rides in every request anyway); rules here are only the cross-cutting // ones. Both sides count against the frozen prompt budget in @@ -420,69 +399,6 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } - /// The minimal profile is a measurement instrument: the persona line and - /// the shell-plus-editor schemas, nothing else, however much grounding - /// the project offers. Instructions, memory, skills, and extension tools - /// on disk are all ignored, or the prefix would vary by project and the - /// measurement would include harness help. - #[test] - fn a_minimal_prompt_ignores_every_grounding_source() { - use crate::registry::Profile; - let dir = temp_project(); - std::fs::write(dir.join("AGENTS.md"), "# Rules\nAlways run the tests.").unwrap(); - std::fs::create_dir_all(dir.join(".openmax/memory")).unwrap(); - std::fs::write(dir.join(".openmax/memory/port.md"), "# The port is 7443\nSet in nginx.conf.").unwrap(); - std::fs::create_dir_all(dir.join(".agents/skills/review")).unwrap(); - std::fs::write( - dir.join(".agents/skills/review/SKILL.md"), - "---\nname: review\ndescription: reviews a diff\n---\nbody", - ) - .unwrap(); - std::fs::create_dir_all(dir.join(".openmax/tools")).unwrap(); - std::fs::write( - dir.join(".openmax/tools/deploy.toml"), - "name = \"deploy\"\ndescription = \"ships it\"\ncommand = \"/bin/echo\"\nmutating = true\n", - ) - .unwrap(); - - let registry = Registry::build_for(Profile::Minimal, &dir.join("data"), &dir); - assert_eq!(registry.tool_names(), crate::tools::MINIMAL_TOOL_NAMES); - assert!(registry.skills.is_empty()); - let (prompt, breakdown) = system_prompt_with_breakdown(&dir, ®istry); - assert_eq!(prompt, MINIMAL_PROMPT); - assert!(!prompt.contains(&*dir.to_string_lossy()), "no checkout path in the measurement prefix"); - assert_eq!(breakdown.components.len(), 1, "one component, the persona line: {:?}", breakdown.components); - assert!(breakdown.memory.is_empty()); - assert!(breakdown.skills.is_empty()); - assert_eq!(breakdown.tools.len(), crate::tools::MINIMAL_TOOL_NAMES.len()); - // Same bytes from a manifest round trip, which is how a resumed - // session rebuilds its prefix. - let resumed = Registry::from_manifest(registry.to_manifest()); - assert_eq!(system_prompt(&dir, &resumed), prompt); - let _ = std::fs::remove_dir_all(dir); - } - - /// The minimal prefix has its own budget: the persona line plus the four - /// schemas, measured the same way as the full budget (no path to strip: - /// the minimal prompt carries none). - #[test] - fn the_minimal_prefix_fits_its_budget() { - use crate::registry::Profile; - let dir = temp_project(); - let registry = Registry::build_for(Profile::Minimal, &dir.join("data"), &dir); - let (prompt, _) = system_prompt_with_breakdown(&dir, ®istry); - let schemas = registry.tool_schemas_wire().len(); - let total = prompt.len() + schemas; - const CAP: usize = 1_600; - assert!( - total <= CAP, - "minimal prefix budget exceeded by {} bytes: prompt {} + schemas {schemas} = {total}, cap {CAP}", - total - CAP, - prompt.len(), - ); - let _ = std::fs::remove_dir_all(dir); - } - /// The extension pointer is part of every frozen prompt: the agent must /// be able to find the surfaces, the command that prints each contract, /// the verifier, and preserved history. Paths, formats, and activation diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs index a8e9bd0f..ce310890 100644 --- a/crates/core/src/registry.rs +++ b/crates/core/src/registry.rs @@ -43,40 +43,6 @@ pub(crate) const MAX_TIMEOUT_SECS: u64 = 300; /// unreviewably long grant list past the human reading the approval. pub(crate) const MAX_ENV_NAMES: usize = 16; -/// Which shape a session freezes. `Full` is the harness: every built-in, -/// the extension surface, and the grounding sections. `Minimal` is the -/// measurement instrument: the shell-plus-editor subset of the built-ins -/// (`tools::MINIMAL_TOOL_NAMES`) behind a one-line prompt, with no -/// extension tools, skills, memory, project instructions, or layout map. It -/// exists so a model can be measured with the least harness help the tools -/// allow, under a prefix that is byte-stable across projects. A profile is -/// fixed for a session's life: the prompt and schemas it froze are the cache -/// prefix every later request extends, and a resumed session keeps them. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Profile { - #[default] - Full, - Minimal, -} - -impl Profile { - pub fn as_str(self) -> &'static str { - match self { - Profile::Full => "full", - Profile::Minimal => "minimal", - } - } - - pub fn parse(s: &str) -> Option { - match s.trim() { - "full" => Some(Profile::Full), - "minimal" => Some(Profile::Minimal), - _ => None, - } - } -} - #[derive(Clone, Debug)] pub enum ToolKind { Builtin, @@ -120,9 +86,6 @@ pub struct ToolSpec { /// or the user forces /reload. Between freezes it is immutable, keeping the /// serialized schema bytes prompt-cache-stable. pub struct Registry { - /// The shape this registry froze; see `Profile`. Persisted in the - /// manifest so a resumed session cannot change shape. - pub profile: Profile, /// Built-ins first in their fixed order, then external tools sorted by /// name — deterministic so two builds serialize identically. pub tools: Vec, @@ -507,66 +470,17 @@ impl Registry { Self::assemble(Vec::new(), Vec::new()) } - /// Freeze a registry for `profile`. `Full` discovers the extension - /// surface exactly as `build` does. `Minimal` freezes the shell-plus-editor - /// subset and nothing from disk, but still fingerprints the extension - /// files, so the ledger can attribute changes the session makes to them - /// (a minimal session can write a tool file it cannot itself load, and - /// the next full session must not record that write as a human's). - pub fn build_for(profile: Profile, data_dir: &Path, project_root: &Path) -> Self { - match profile { - Profile::Full => Self::build(data_dir, project_root), - Profile::Minimal => { - Self::minimal_at(capture_extensions(data_dir, project_root).fingerprint()) - } - } - } - - /// The minimal registry pinned to one extension generation: same tools - /// and prompt as every other minimal registry, differing only in the - /// fingerprint the refreeze checks compare against disk. - pub(crate) fn minimal_at(ext_fingerprint: u64) -> Self { - let mut registry = Self::assemble_for(Profile::Minimal, Vec::new(), Vec::new()); - registry.ext_fingerprint = ext_fingerprint; - registry - } - - pub(crate) fn assemble(external: Vec, skills: Vec) -> Self { - Self::assemble_for(Profile::Full, external, skills) - } - - pub(crate) fn assemble_for(profile: Profile, mut external: Vec, mut skills: Vec) -> Self { + pub(crate) fn assemble(mut external: Vec, skills: Vec) -> Self { // Built-ins come straight from the canonical schema literals so the - // registry can never drift from what tools.rs implements. The minimal - // profile keeps the subset in the same order: the full array with - // entries removed, never a reordering. - let keep = |name: &str| profile == Profile::Full || tools::MINIMAL_TOOL_NAMES.contains(&name); - let mut tools_list: Vec = - builtin_specs().into_iter().filter(|s| keep(&s.name)).collect(); - if profile == Profile::Minimal { - // No extension surface: nothing discovered on disk is loaded, so a - // manifest or caller handing some over cannot smuggle it in. - external.clear(); - skills.clear(); - } + // registry can never drift from what tools.rs implements. + let mut tools_list = builtin_specs(); // Built-in names win over external ones: shadowing a built-in would // silently change core behavior mid-workflow. external.retain(|t| !tools::TOOL_NAMES.contains(&t.name.as_str())); external.sort_by(|a, b| a.name.cmp(&b.name)); tools_list.extend(external); - let mut schemas = match profile { - Profile::Full => tools::tool_schemas().clone(), - Profile::Minimal => Value::Array( - tools::tool_schemas() - .as_array() - .expect("builtin schemas are an array") - .iter() - .filter(|entry| keep(entry["function"]["name"].as_str().unwrap_or(""))) - .cloned() - .collect(), - ), - }; + let mut schemas = tools::tool_schemas().clone(); if let Some(arr) = schemas.as_array_mut() { for spec in tools_list.iter().filter(|s| !matches!(s.kind, ToolKind::Builtin)) { arr.push(serde_json::json!({ @@ -586,12 +500,7 @@ impl Registry { .map(|(i, s)| (s.name.clone(), i)) .collect(); let schemas_wire: Arc = schemas.to_string().into(); - // The minimal profile indexes no memory: it reports itself as scanned - // with an empty section so prompt assembly never scans for it, and - // prices zero rows on every path (live or persisted). - let minimal = profile == Profile::Minimal; Self { - profile, tools: tools_list, skills, skills_omitted: 0, @@ -602,9 +511,9 @@ impl Registry { broken_tools: Vec::new(), shadowed_skills: Vec::new(), memory_files: None, - frozen_memory_rows: if minimal { Some(Vec::new()) } else { None }, + frozen_memory_rows: None, memory_section: None, - memory_scanned: minimal, + memory_scanned: false, schemas, schemas_wire, by_name, @@ -754,10 +663,6 @@ impl Default for Registry { pub struct RegistryManifest { pub version: u32, - /// The shape the session froze. Additive: manifests written before the - /// field read as `full`, which is what every one of them was. - #[serde(default)] - pub profile: Profile, pub external_tools: Vec, pub skills: Vec, /// Fingerprint of the extension files at freeze time. Manifests written @@ -794,7 +699,14 @@ pub struct RegistryManifest { // parsing it back out of the prompt was forgeable by newline-bearing // filenames rendered into later sections). Old manifests read as absent // and refreeze, so every live manifest carries exact rows. -pub const MANIFEST_VERSION: u32 = 4; +// 5: for one day the manifest carried a session-shape field for a second, +// bare shape (four tools under a one-line prompt) that was then removed. A +// v4 manifest from that build parsed as a full one while its transcript +// kept the bare prompt: seven tools under a one-line prompt, neither shape, +// and no fingerprint change to repair it. Reading v4 as absent retires +// those records the way this constant always has: built-ins until the next +// turn start, which refreezes prompt and manifest from disk together. +pub const MANIFEST_VERSION: u32 = 5; #[derive(serde::Serialize, serde::Deserialize)] pub struct ExternalToolManifest { @@ -840,7 +752,6 @@ impl Registry { }) .collect(); RegistryManifest { - profile: self.profile, memory_files: self.memory_files.clone(), // The frozen channel, not a re-parse: a restored registry // re-suspending must keep the accounting its persisted prompt @@ -875,7 +786,7 @@ impl Registry { }), }) .collect(); - let mut registry = Self::assemble_for(manifest.profile, external, manifest.skills); + let mut registry = Self::assemble(external, manifest.skills); registry.ext_fingerprint = manifest.ext_fingerprint; // The manifest's memory identities are the baseline for the first // refreeze's memory receipt. A resumed session's prompt is the diff --git a/crates/core/src/sessions.rs b/crates/core/src/sessions.rs index 2b98596b..9dc6075a 100644 --- a/crates/core/src/sessions.rs +++ b/crates/core/src/sessions.rs @@ -140,7 +140,7 @@ fn messages_path(core: &Core, id: &str) -> PathBuf { sessions_dir(core).join(format!("{id}.messages.json")) } -pub(crate) fn manifest_path(core: &Core, id: &str) -> PathBuf { +fn manifest_path(core: &Core, id: &str) -> PathBuf { sessions_dir(core).join(format!("{id}.manifest.json")) } @@ -1347,7 +1347,6 @@ mod tests { assert!(load_messages(&core, &id).unwrap().is_none(), "a deleted transcript stays deleted"); save_manifest(&core, &id, &crate::registry::RegistryManifest { version: 1, - profile: crate::registry::Profile::Full, external_tools: Vec::new(), skills: Vec::new(), ext_fingerprint: 0, diff --git a/crates/core/src/tools.rs b/crates/core/src/tools.rs index 08bb1e3e..9ce2d72f 100644 --- a/crates/core/src/tools.rs +++ b/crates/core/src/tools.rs @@ -140,15 +140,6 @@ pub fn tool_names() -> Vec { TOOL_NAMES.iter().map(|s| s.to_string()).collect() } -/// The built-ins the minimal profile freezes, in `TOOL_NAMES` order: a shell -/// plus a file editor split into its read, create, and replace verbs. That is -/// the capability set of the two-tool scaffold most published coding -/// benchmarks run a model under, at a fraction of its schema bytes, so a -/// model measured under this profile is measured with the least harness help -/// these tools allow. Discovery stays out: `bash` covers `ls`, `find`, and -/// `grep`, and every extra schema is a hint the measurement should not give. -pub const MINIMAL_TOOL_NAMES: &[&str] = &["read_file", "write_file", "edit_file", "bash"]; - /// One-line human summary of a call, shown in approval prompts and tool cards. pub fn summarize_call(name: &str, args: &Value) -> String { match name { @@ -1056,23 +1047,6 @@ mod tests { use super::*; use serde_json::json; - /// The minimal set is a subset of the built-ins in their frozen order, so - /// a minimal registry's schema array is the full array with entries - /// removed and nothing reordered: deterministic bytes, like every other - /// prompt-cache prefix. A shell and an editor are the floor; a profile - /// without either would measure a model that cannot act. - #[test] - fn minimal_tool_names_are_an_ordered_subset_of_the_builtins() { - let positions: Vec = MINIMAL_TOOL_NAMES - .iter() - .map(|n| TOOL_NAMES.iter().position(|t| t == n).expect("every minimal tool is a built-in")) - .collect(); - assert!(positions.windows(2).all(|w| w[0] < w[1]), "minimal tools keep the built-in order"); - assert!(MINIMAL_TOOL_NAMES.contains(&"bash")); - assert!(MINIMAL_TOOL_NAMES.contains(&"edit_file")); - assert!(MINIMAL_TOOL_NAMES.len() < TOOL_NAMES.len()); - } - #[tokio::test] async fn a_started_file_mutation_settles_before_cancellation_returns() { let root = temp_project(); diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 9225a31c..67991aa9 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -218,9 +218,6 @@ fn conversation_layout( pub struct Args { pub continue_session: bool, - /// The shape a session this process creates freezes under; a resumed - /// session keeps its own. - pub profile: registry::Profile, } #[derive(PartialEq)] @@ -239,8 +236,6 @@ struct ToolMeta { pub struct App { core: Arc, project: PathBuf, - /// Profile for sessions this process creates (`--profile`). - profile: registry::Profile, session_id: Option, /// A /resume (or --continue) picked this session and no turn has /// hydrated it yet: /context's numbers are still today's-config @@ -531,7 +526,6 @@ impl App { composer: Composer::new(&core.data_dir), core, project, - profile: registry::Profile::Full, session_id: None, resumed_awaiting_hydration: false, pending_submit: None, @@ -607,18 +601,7 @@ impl App { } } - /// The profile the current session froze (its manifest is the record), - /// or the one this process will give the next new session. - fn session_profile(&self) -> registry::Profile { - self.session_id - .as_deref() - .and_then(|id| sessions::load_manifest(&self.core, id)) - .map(|m| m.profile) - .unwrap_or(self.profile) - } - async fn startup(&mut self, args: &Args) { - self.profile = args.profile; if args.continue_session { let project = self.project.display().to_string(); match sessions::latest(&self.core, &project) { @@ -2050,14 +2033,6 @@ impl App { None => { let meta = sessions::create(&self.core, self.project.display().to_string()) .map_err(std::io::Error::other)?; - if let Err(e) = agent::freeze_new_session(&self.core, &meta.id, &self.project, self.profile) { - // Refused, not degraded: the draft goes back to the - // composer and no session is left behind. - let _ = sessions::discard_if_empty(&self.core, &meta.id); - self.note(&e); - self.composer.load(&text); - return Ok(()); - } self.session_id = Some(meta.id.clone()); self.resumed_awaiting_hydration = false; meta.id @@ -2485,7 +2460,6 @@ impl App { kv("endpoint", &endpoint), kv("host", &host), kv("approvals", self.core.approval_mode(&self.project).as_str()), - kv("profile", self.session_profile().as_str()), kv( "context", &if context_tokens == 0 { diff --git a/crates/tui/src/headless.rs b/crates/tui/src/headless.rs index 364e0b32..f9398f9e 100644 --- a/crates/tui/src/headless.rs +++ b/crates/tui/src/headless.rs @@ -7,7 +7,6 @@ use std::sync::Arc; use std::time::Duration; use open_max_core::agent; -use open_max_core::registry::Profile; use open_max_core::text::one_line; use open_max_core::sessions; use open_max_core::state::Core; @@ -20,9 +19,6 @@ pub struct HeadlessArgs { pub prompts: Vec, pub continue_session: bool, pub json: bool, - /// The shape a session this run creates freezes under; a continued - /// session keeps its own. - pub profile: Profile, } /// Run one or more agent turns and exit when the last finishes. Approvals in @@ -46,14 +42,7 @@ pub async fn run( } } else { match sessions::create(&core, project_key) { - Ok(meta) => { - if let Err(e) = agent::freeze_new_session(&core, &meta.id, &project, args.profile) { - eprintln!("openmax: {e}"); - let _ = sessions::discard_if_empty(&core, &meta.id); - return 1; - } - meta.id - } + Ok(meta) => meta.id, Err(e) => { eprintln!("openmax: failed to create session: {e}"); return 1; diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 71e682de..28872c60 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -25,12 +25,6 @@ options: -c, --continue resume the latest session in this directory -m, --model use this model id for the run --provider use a named provider from ~/.openmax/providers.json - --profile full (default) or minimal. minimal freezes only - read_file, write_file, edit_file, and bash behind a - one-line prompt: no project instructions, layout map, - skills, memory, hooks, or extension tools. For - measuring a model with the least harness help; new - sessions only -p, --print headless: run one turn and exit (prompt required; repeat -p for multi-turn on the same session) --json with --print, emit AgentEvent envelopes as JSONL; @@ -87,8 +81,6 @@ struct CliArgs { continue_session: bool, model: Option, provider: Option, - /// Session shape for a new session (`--profile`), validated in main. - profile: Option, print: bool, json: bool, stdio: bool, @@ -124,7 +116,6 @@ where continue_session: false, model: None, provider: None, - profile: None, print: false, json: false, stdio: false, @@ -147,7 +138,6 @@ where Short('c') | Long("continue") => out.continue_session = true, Short('m') | Long("model") => out.model = Some(parser.value()?.string()?), Long("provider") => out.provider = Some(parser.value()?.string()?), - Long("profile") => out.profile = Some(parser.value()?.string()?), Short('p') | Long("print") => { if out.print { // Subsequent -p closes the previous prompt; empty is an error. @@ -265,28 +255,6 @@ async fn main() -> std::io::Result<()> { eprintln!("openmax: --json requires --print, --check, or --recall\n\n{HELP}"); std::process::exit(2); } - // Validated before anything runs: an unknown profile must not fall back - // to the full one, or a measurement run would silently measure the - // wrong harness. - let profile = match cli.profile.as_deref() { - None => open_max_core::registry::Profile::Full, - Some(name) => match open_max_core::registry::Profile::parse(name) { - Some(profile) => profile, - None => { - eprintln!("openmax: unknown profile {name:?}; use full or minimal\n\n{HELP}"); - std::process::exit(2); - } - }, - }; - // A session's profile is fixed at creation (its prompt and schemas are - // the cache prefix every later request extends), so the flag on a - // resume is an error rather than a silent no-op. - if cli.profile.is_some() && cli.continue_session { - eprintln!( - "openmax: --profile applies to a new session; a resumed session keeps the profile it was created with\n\n{HELP}" - ); - std::process::exit(2); - } // --recall prints one report and exits, like --spec: swallowing another // requested operation would look like success for work that never ran. if cli.recall.is_some() @@ -300,7 +268,6 @@ async fn main() -> std::io::Result<()> { || cli.spec.is_some() || cli.model.is_some() || cli.provider.is_some() - || cli.profile.is_some() || !cli.prompts.is_empty()) { eprintln!("openmax: --recall is a standalone operation; run other options separately\n\n{HELP}"); @@ -326,7 +293,6 @@ async fn main() -> std::io::Result<()> { || cli.continue_session || cli.model.is_some() || cli.provider.is_some() - || cli.profile.is_some() || !cli.prompts.is_empty()) { eprintln!("openmax: --spec is a standalone operation; run other options separately\n\n{HELP}"); @@ -338,16 +304,6 @@ async fn main() -> std::io::Result<()> { eprintln!("openmax: --run-examples requires --check\n\n{HELP}"); std::process::exit(2); } - // --profile shapes a session. The maintenance operations run none, so - // accepting the flag there would look like it took effect. - if cli.profile.is_some() - && (cli.check || cli.ledger || cli.ledger_repair || cli.approve.is_some() || cli.forget.is_some()) - { - eprintln!( - "openmax: --profile applies to a session run (interactive, --print, or --stdio), not to --check, --ledger, --ledger-repair, --approve, or --forget\n\n{HELP}" - ); - std::process::exit(2); - } if let Some(surface) = &cli.spec { // `usage` is the one dynamic surface: it joins each extension's @@ -978,7 +934,7 @@ async fn main() -> std::io::Result<()> { let code = stdio::run( core, core_rx, - stdio::StdioArgs { continue_session: cli.continue_session, profile }, + stdio::StdioArgs { continue_session: cli.continue_session }, ) .await; std::process::exit(code); @@ -996,7 +952,6 @@ async fn main() -> std::io::Result<()> { prompts: cli.prompts, continue_session: cli.continue_session, json: cli.json, - profile, }, ) .await; @@ -1043,7 +998,7 @@ async fn main() -> std::io::Result<()> { terminal, core, core_rx, - app::Args { continue_session: cli.continue_session, profile }, + app::Args { continue_session: cli.continue_session }, ) .await; @@ -1876,14 +1831,6 @@ mod tests { assert!(cli.check && cli.stdio && !cli.print && cli.prompts.is_empty()); } - #[test] - fn profile_flag_takes_a_name() { - let cli = parse_args_from(["--profile", "minimal", "-p", "inspect"]).unwrap(); - assert_eq!(cli.profile.as_deref(), Some("minimal")); - assert!(cli.print); - assert!(parse_args_from(["--profile"]).is_err()); - } - #[test] fn trust_project_flag_parses() { let cli = parse_args_from(["--trust-project", "-p", "inspect"]).unwrap(); diff --git a/crates/tui/src/stdio.rs b/crates/tui/src/stdio.rs index c0bbcd6f..cd34b477 100644 --- a/crates/tui/src/stdio.rs +++ b/crates/tui/src/stdio.rs @@ -69,9 +69,6 @@ enum Command { pub struct StdioArgs { pub continue_session: bool, - /// The shape a session this run creates freezes under; a continued - /// session keeps its own. - pub profile: open_max_core::registry::Profile, } pub async fn run( @@ -92,14 +89,7 @@ pub async fn run( } } else { match sessions::create(&core, project_key.clone()) { - Ok(meta) => { - if let Err(e) = agent::freeze_new_session(&core, &meta.id, &project, args.profile) { - eprintln!("openmax: {e}"); - let _ = sessions::discard_if_empty(&core, &meta.id); - return 1; - } - (meta.id, false) - } + Ok(meta) => (meta.id, false), Err(e) => { eprintln!("openmax: failed to create session: {e}"); return 1; diff --git a/crates/tui/tests/cli.rs b/crates/tui/tests/cli.rs index cf0b74e4..9734ffd8 100644 --- a/crates/tui/tests/cli.rs +++ b/crates/tui/tests/cli.rs @@ -1547,78 +1547,3 @@ fn saved_project_auto_applies_to_headless_and_cannot_be_changed_by_a_child() { assert_eq!(settings["approval_mode"], "ask", "the global default must not change"); let _ = std::fs::remove_dir_all(project.parent().unwrap()); } - -/// The minimal profile is the measurement shape: four tools behind a persona -/// line, byte-identical across projects. A built-in the profile hides is -/// refused at dispatch like any unknown tool, listing what is available, so -/// the model cannot reach past the profile by guessing a name. -#[test] -fn a_minimal_profile_turn_freezes_four_tools_and_refuses_hidden_builtins() { - let (project, home) = fresh_dirs("profile-minimal"); - std::fs::write(project.join("AGENTS.md"), "# Rules\nAlways run the tests.").unwrap(); - let script = vec![ - (sse_tool_calls(&[("glob", serde_json::json!({"pattern": "**/*.rs"}))]), true), - (sse_text("done"), true), - ]; - let (base_url, requests, _server) = spawn_scripted_server(script); - write_settings_with_mode(&home, &base_url, "auto"); - - let out = cmd(&project, &home) - .args(["--trust-project", "--profile", "minimal", "-p", "look around"]) - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(0), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - - let bodies = requests.lock().unwrap().clone(); - assert_eq!(bodies.len(), 2, "one refused call, one answer"); - let first: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap(); - let names: Vec<&str> = first["tools"] - .as_array() - .unwrap() - .iter() - .map(|t| t["function"]["name"].as_str().unwrap()) - .collect(); - assert_eq!(names, ["read_file", "write_file", "edit_file", "bash"]); - let system = first["messages"][0]["content"].as_str().unwrap(); - assert_eq!( - system, - "You are Open Max, a coding agent working in the current project. Tool paths are project-relative.", - "the measurement prompt names no path and carries no project text" - ); - let second: serde_json::Value = serde_json::from_str(&bodies[1]).unwrap(); - let reply = second["messages"] - .as_array() - .unwrap() - .iter() - .rev() - .find(|m| m["role"] == "tool") - .expect("the refused call still gets a tool reply"); - let text = reply["content"].as_str().unwrap(); - assert!(text.contains("unknown tool: glob"), "{text}"); - assert!(text.contains("read_file, write_file, edit_file, bash"), "{text}"); - let _ = std::fs::remove_dir_all(project.parent().unwrap()); -} - -/// The flag is validated before anything runs, and a resumed session keeps -/// the profile it was created with, so combining the flag with --continue is -/// an error rather than a silent no-op. -#[test] -fn the_profile_flag_rejects_unknown_names_and_continuation() { - let (project, home) = fresh_dirs("profile-flag"); - write_settings(&home, "http://127.0.0.1:9/v1"); - let out = cmd(&project, &home).args(["--profile", "tiny", "-p", "hi"]).output().unwrap(); - assert_eq!(out.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&out.stderr).contains("unknown profile")); - let out = cmd(&project, &home) - .args(["--profile", "minimal", "--continue", "-p", "hi"]) - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&out.stderr).contains("--profile applies to a new session")); - // The maintenance operations run no session, so the flag is refused - // there too rather than accepted and ignored. - let out = cmd(&project, &home).args(["--check", "--profile", "minimal"]).output().unwrap(); - assert_eq!(out.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&out.stderr).contains("--profile applies to a session run")); - let _ = std::fs::remove_dir_all(project.parent().unwrap()); -} diff --git a/docs/usage.md b/docs/usage.md index d4bd3639..7c6c126b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,7 +17,6 @@ openmax -c openmax --provider ollama --model qwen2.5-coder:7b openmax -p "summarize the top level layout of this repo" openmax -p --json "list public modules in crates/core" -openmax --profile minimal -p "fix the failing test" # measurement shape: four tools, bare prompt openmax --check # validate extension files openmax --spec hooks # print an extension surface's contract openmax --recall "deploy port" # search past sessions and memories @@ -41,17 +40,6 @@ each `AgentEvent` is one JSON line on stdout. Mutating tools still honor `approval_mode`; select `/approvals auto` once in the trusted project for unattended runs. -`--profile minimal` starts a session with only `read_file`, `write_file`, -`edit_file`, and `bash`, a one-line system prompt that names no path, and -nothing from the project: no `AGENTS.md`, layout map, skills, memory, hooks, -or extension tools (permission rules still apply). It is the shape to measure -a model under: the prefix is byte-identical across checkouts and carries no -help beyond the tools themselves. The profile is fixed when the session is -created; a session that cannot record it refuses to start rather than running -as a full one. `--continue` and `/resume` keep whatever profile a session has, -`/status` shows it, and extension files written during a minimal session are -recorded in the ledger but never loaded into it. - `openmax --stdio` is the contract for custom frontends, editor integrations, and one openmax driving another. It is specified in [stdio protocol](stdio-protocol.md).