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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
328 changes: 61 additions & 267 deletions crates/core/src/agent.rs

Large diffs are not rendered by default.

21 changes: 0 additions & 21 deletions crates/core/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HookFailure>,
/// 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
Expand Down Expand Up @@ -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.
///
Expand Down
84 changes: 0 additions & 84 deletions crates/core/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, &registry);
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, &registry);
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
Expand Down
119 changes: 15 additions & 104 deletions crates/core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
match s.trim() {
"full" => Some(Profile::Full),
"minimal" => Some(Profile::Minimal),
_ => None,
}
}
}

#[derive(Clone, Debug)]
pub enum ToolKind {
Builtin,
Expand Down Expand Up @@ -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<ToolSpec>,
Expand Down Expand Up @@ -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<ToolSpec>, skills: Vec<SkillSpec>) -> Self {
Self::assemble_for(Profile::Full, external, skills)
}

pub(crate) fn assemble_for(profile: Profile, mut external: Vec<ToolSpec>, mut skills: Vec<SkillSpec>) -> Self {
pub(crate) fn assemble(mut external: Vec<ToolSpec>, skills: Vec<SkillSpec>) -> 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<ToolSpec> =
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!({
Expand All @@ -586,12 +500,7 @@ impl Registry {
.map(|(i, s)| (s.name.clone(), i))
.collect();
let schemas_wire: Arc<str> = 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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<ExternalToolManifest>,
pub skills: Vec<SkillSpec>,
/// Fingerprint of the extension files at freeze time. Manifests written
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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
Expand Down
3 changes: 1 addition & 2 deletions crates/core/src/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading