diff --git a/SPEC.md b/SPEC.md index b5d62280..bf0fc828 100644 --- a/SPEC.md +++ b/SPEC.md @@ -416,6 +416,33 @@ TOOL taking a hash (`test` and `test-result`), not as richer printing. error there takes the agent's turn down with it - Unexpected failures die, per the reliability principles above +# Repository agent instructions (`.caos/agent.json`) + +A repository steers the agent with a checked-in `.caos/agent.json`: + +```json +{"instructions": "Build with `nix build`. Run the `test` tool before finishing a change."} +``` + +- `instructions` (a string) is standing guidance for every agent turn on that + tree: build/test commands, conventions, what "ready to publish" means. + Unknown fields are ignored so the schema can grow without breaking a + harness already baked into a worker image; a malformed file — bad JSON, or + a non-string `instructions` — fails the round loudly rather than silently + running without the repository's rules. +- `llm-step` rereads the file from the CURRENT workspace on every round and + appends it to the curried system prompt — the same freshness contract as + the tool registry, so an agent that edits the file sees the change on its + next request. A subagent's snapshot keeps `agent.json` (and only it) when + the parent's `.caos` harness state is stripped. +- `agent.json` is the ONE `.caos` entry a workspace may carry at rest. + Conversation bases, `/update-tree` proposals, and published PR tips accept + it and refuse everything else under `.caos`; the inline file tools read and + edit it like any other file. +- Per-repo validation policy lives here: the TUI's publication-preparation + turn no longer instructs a generic "build and test", so a repository that + wants gates before a PR names them in its own instructions. + # Merging and conflict resolution An agent resolves a git merge from inside a conversation. The obstacle is @@ -553,9 +580,9 @@ step.json exists ONLY in a step-commit tree (`mint_step` injects it), never in inside a step tree, and the harness tells them apart by FILENAME. So four small local rules, no "persistence exemption": -- **Inline tools** refuse `.caos/step.json` specifically, not all of `.caos/`, - so `.caos/conflicts` is editable like any file (deleting a path's rows is an - `edit`). +- **Inline tools** refuse the reserved `.caos/` names, not the directory + wholesale: `.caos/conflicts` (deleting a path's rows is an `edit`) and the + checked-in `.caos/agent.json` are editable like any file. - **`mint_step`** PRESERVES an existing `.caos/` when it injects `step.json` (symlinking `.caos/conflicts` in alongside), rather than assuming `.caos/` is absent. @@ -563,10 +590,11 @@ local rules, no "persistence exemption": included — it is workspace state. (A build run mid-merge keys on a tree that still carries the file, so it won't cache-hit the post-resolution build; negligible, and only during resolution.) -- **Publish** (the tui's PR flow) requires the conversation TIP to have no - `.caos/` entry after the guard below. Earlier merge commits remain in the - published history with their conflict scaffolding, but the PR's final tree - cannot carry even a leftover empty `.caos/conflicts`. +- **Publish** (the tui's PR flow) requires the conversation TIP's `.caos/` to + hold nothing beyond a checked-in `agent.json` after the guard below. Earlier + merge commits remain in the published history with their conflict + scaffolding, but the PR's final tree cannot carry even a leftover empty + `.caos/conflicts`. Both `.caos/conflicts` and the inline markers sit in the diff the whole time, so a mid-merge head is fully reviewable. diff --git a/design/chat.md b/design/chat.md index 2e6a8ee4..e3525556 100644 --- a/design/chat.md +++ b/design/chat.md @@ -110,7 +110,9 @@ A root user event is schematically: There is no `v` field. Durable object and request IDs accepted at protocol boundaries are canonical lowercase 40-character Git SHA-1 values. Ordinary workspace bases and user proposals may not introduce the reserved top-level -`.caos` path. +`.caos` path, with one exception: a checked-in `.caos/agent.json` carrying +repository instructions for the agent (SPEC, "Repository agent +instructions"). Replay walks the first-parent event spine through its first `base` event, whose value must equal that event's first parent; ancestry below it is ordinary @@ -237,11 +239,13 @@ histories differ. This applies only the user's `P..U` delta instead of treating all changes since `P` as user edits. PR publication is an ordinary turn: when the exact fetched base is not already -an ancestor of the conversation head, the agent calls `merge` with it, then -resolves and tests. When it is already an ancestor, the turn is explicitly told -not to merge it again. A clean conversation head without `.caos` becomes the PR -branch tip, preserving the conversation history so later publications are -fast-forwards. +an ancestor of the conversation head, the agent calls `merge` with it and +resolves the result. When it is already an ancestor, the turn is explicitly +told not to merge it again. Validation policy comes from the repository's own +`.caos/agent.json` instructions rather than a generic build-and-test request. +A clean conversation head whose `.caos` holds nothing beyond that checked-in +`agent.json` becomes the PR branch tip, preserving the conversation history so +later publications are fast-forwards. `/from` materializes a new conversation before its first new message. Its source must be a recognized conversation event whose complete inherited event diff --git a/rust/crates/caos-cli/TUI.md b/rust/crates/caos-cli/TUI.md index bfd6f29b..965332e7 100644 --- a/rust/crates/caos-cli/TUI.md +++ b/rust/crates/caos-cli/TUI.md @@ -225,11 +225,14 @@ matches it exactly. Publishing also leaves the checkout untouched. The first `Ctrl+P` opens a base branch prompt with `origin`'s advertised default selected; type another branch -to override it, then press `Ctrl+P` again. CAOS starts a visible agent turn that -merges the exact fetched base with the standard `merge` tool, then resolves and -tests the result. When that fetched base is already an ancestor of the current -conversation head, the preparation turn runs without asking the agent to merge -it again. For another base, only this conversation's delta is applied, so child +to override it, then press `Ctrl+P` again. CAOS starts a visible agent turn +that merges the exact fetched base with the standard `merge` tool and resolves +the result, validated per the repository's checked-in `.caos/agent.json` +instructions (SPEC, "Repository agent instructions") — the request itself no +longer imposes a generic build-and-test step. When that fetched base is +already an ancestor of the current conversation head, the preparation turn +runs without asking the agent to merge it again. +For another base, only this conversation's delta is applied, so child conversations form clean PR stacks. Unresolved conflicts stop before the branch moves. The checkout and index remain untouched. @@ -238,14 +241,14 @@ pushes it, and uses the authenticated `gh` CLI to find or open its pull request. The first publication carries the conversation history; later publications advance the branch by fast-forward. The selected PR base is an ancestor of the published head, and the final tree must contain no reserved -`.caos` state. +`.caos` state beyond a checked-in `.caos/agent.json`. -`/publish-branch` is the sharing-only form of publication: it does not run the -agent merge-and-test preparation turn and does not create a PR. It still checks -the current tip for unresolved conflict records, conflict markers, and reserved -`.caos` state before moving the branch. It can publish a completed conversation -whose workspace diff is empty, since the transcript history itself is useful -to another client. +`/publish-branch` is the sharing-only form of publication: it does not run +the agent merge-and-validate preparation turn and does not create a PR. It +still checks the current tip for unresolved conflict records, conflict +markers, and reserved `.caos` state before moving the branch. It can publish +a completed conversation whose workspace diff is empty, since the transcript +history itself is useful to another client. `/update-tree ` is the one command that reads the working tree back into a conversation. It sends an ordinary user turn — authored by your git @@ -258,7 +261,8 @@ the snapshot covers tracked edits, new files, and deletions, honoring left clean. That matters: after a later agent turn you can press `Ctrl+L` again to check out the new head without the clean-tree guard tripping on leftover local changes. The agent then runs over the changes you folded in. A working -tree carrying the harness's reserved top-level `.caos` entry is refused. +tree carrying reserved top-level `.caos` state (anything beyond a checked-in +`.caos/agent.json`) is refused. The intended loop is `Ctrl+L` (check out the head) → edit files → `/update-tree ` → let the agent respond → `Ctrl+L` again. You can also commit the diff --git a/rust/crates/caos-cli/src/bin/tui/app.rs b/rust/crates/caos-cli/src/bin/tui/app.rs index df1dce90..105166d6 100644 --- a/rust/crates/caos-cli/src/bin/tui/app.rs +++ b/rust/crates/caos-cli/src/bin/tui/app.rs @@ -4003,19 +4003,21 @@ impl App { } } +// No generic "build and test" here: how a workspace is validated is the +// repository's call, made through its checked-in `.caos/agent.json` +// instructions (folded into the agent's system prompt), not this client's. fn publish_turn_message(target: &str, base_is_ancestor: bool) -> String { if base_is_ancestor { format!( "Prepare this conversation for publication. The selected PR base `{target}` is \ already an ancestor of this conversation, so do not call `merge` for it again. \ - Build and test, then finish only when the workspace is ready to publish." + Finish only when the workspace is ready to publish." ) } else { format!( "Prepare this conversation for publication. First call the existing `merge` tool \ - with `theirs` exactly `{target}`. Resolve every entry in `.caos/conflicts`, remove \ - `.caos/conflicts`, then build and test. Finish only when the workspace is ready to \ - publish." + with `theirs` exactly `{target}`. Resolve every entry in `.caos/conflicts` and \ + remove `.caos/conflicts`. Finish only when the workspace is ready to publish." ) } } @@ -4125,6 +4127,19 @@ mod tests { assert!(needs_merge.contains("`theirs` exactly `abc123`")); } + #[test] + fn publish_prompts_leave_validation_to_the_repository_instructions() { + // Validation policy lives in the repo's `.caos/agent.json`, which the + // harness folds into the system prompt; the publish request must not + // impose a generic build-and-test step on repositories that define + // their own. + for base_is_ancestor in [true, false] { + let message = publish_turn_message("abc123", base_is_ancestor); + assert!(!message.to_lowercase().contains("build and test")); + assert!(message.contains("ready to publish")); + } + } + fn summary(id: &str) -> UserConversationSummary { UserConversationSummary { id: id.to_string(), diff --git a/rust/crates/caos-cli/src/bin/tui/workspace.rs b/rust/crates/caos-cli/src/bin/tui/workspace.rs index 1deb9954..ff39840d 100644 --- a/rust/crates/caos-cli/src/bin/tui/workspace.rs +++ b/rust/crates/caos-cli/src/bin/tui/workspace.rs @@ -3,6 +3,8 @@ use std::path::Path; use std::process::{Command, Output}; +use caos_cli::AGENT_CONFIG_NAME; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PreparedPublishConversation { pub(crate) head: String, @@ -138,12 +140,27 @@ pub(crate) fn prepare_publish_workspace( require_success("git grep", markers)?; } - let reserved = capture_required("git", &["ls-tree", "--name-only", head, ".caos"], cwd)?; - if !reserved.is_empty() { - return Err( - "the publish head still contains reserved .caos state; remove .caos/conflicts after resolving it" - .to_string(), - ); + let caos_entry = capture_required("git", &["ls-tree", "--name-only", head, ".caos"], cwd)?; + if !caos_entry.is_empty() { + // `.caos/agent.json` — checked-in repository instructions — publishes + // with the tree; everything else under `.caos` is harness state and + // must be gone by the tip (even an empty, resolved conflict record). + let entries = capture_required( + "git", + &["ls-tree", "--name-only", &format!("{head}:.caos")], + cwd, + )?; + let reserved: Vec<&str> = entries + .lines() + .filter(|name| !name.is_empty() && *name != AGENT_CONFIG_NAME) + .collect(); + if !reserved.is_empty() { + return Err(format!( + "the publish head still contains reserved .caos state ({}); \ + remove .caos/conflicts after resolving it", + reserved.join(", ") + )); + } } Ok(PreparedPublishConversation { @@ -791,6 +808,47 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn prepared_publish_head_keeps_checked_in_agent_instructions() { + let dir = temp_repo("publish-agent-config"); + let base = commit_file(&dir, "base\n", "base"); + std::fs::create_dir_all(dir.join(".caos")).unwrap(); + std::fs::write( + dir.join(".caos/agent.json"), + "{\"instructions\":\"run the tests\"}", + ) + .unwrap(); + capture_required("git", &["add", ".caos/agent.json"], &dir).unwrap(); + capture_required("git", &["commit", "-q", "-m", "instructions"], &dir).unwrap(); + let head = capture_required("git", &["rev-parse", "HEAD"], &dir).unwrap(); + + let prepared = prepare_publish_workspace(&head, &base, &dir).unwrap(); + + assert_eq!(prepared.head, head); + } + + #[test] + fn prepared_publish_head_refuses_conflicts_beside_agent_instructions() { + let dir = temp_repo("publish-agent-config-conflicts"); + let base = commit_file(&dir, "base\n", "base"); + std::fs::create_dir_all(dir.join(".caos")).unwrap(); + std::fs::write( + dir.join(".caos/agent.json"), + "{\"instructions\":\"run the tests\"}", + ) + .unwrap(); + std::fs::write(dir.join(".caos/conflicts"), "").unwrap(); + capture_required("git", &["add", ".caos"], &dir).unwrap(); + capture_required("git", &["commit", "-q", "-m", "leftover record"], &dir).unwrap(); + let head = capture_required("git", &["rev-parse", "HEAD"], &dir).unwrap(); + + let error = prepare_publish_workspace(&head, &base, &dir).unwrap_err(); + + assert!(error.contains("reserved .caos state")); + assert!(error.contains("conflicts")); + assert!(!error.contains("agent.json")); + } + #[test] fn prepared_publish_head_refuses_unresolved_conflicts() { let dir = temp_repo("publish-conflicts"); diff --git a/rust/crates/caos-cli/src/lib.rs b/rust/crates/caos-cli/src/lib.rs index 9f574f73..461271bb 100644 --- a/rust/crates/caos-cli/src/lib.rs +++ b/rust/crates/caos-cli/src/lib.rs @@ -39,6 +39,10 @@ pub const MODEL_API_SECRET_VALUE_FILE: &str = ".anthropic-api-key-value"; /// The readers granted the model credential: both conversation LLM workers /// (the turn engine, and the stateless title/auxiliary caller). pub const MODEL_API_SECRET_READERS: [&str; 2] = ["DEEP-DEPS/llm-step", "DEEP-DEPS/llm-call"]; +/// The one `.caos` entry a workspace may carry at rest: checked-in repository +/// instructions the agent harness folds into its system prompt (SPEC, +/// "Repository agent instructions"). +pub const AGENT_CONFIG_NAME: &str = "agent.json"; const AUTO_NAME_PREFIX: &str = "talk-"; const MERGE_REF_CANDIDATES: &[&str] = &["main", "master", "origin/main", "origin/master"]; pub const DEFAULT_MODEL: &str = "claude-opus-4-8"; @@ -424,13 +428,26 @@ fn reject_reserved_caos(t: &GitTransport, root: &str, what: &str) -> Result<(), &["rev-parse", "--verify", "--quiet", &format!("{root}:.caos")], None, ) - .is_ok() + .is_err() { - return Err(format!( - "the {what} contains top-level .caos state; choose a clean workspace" - )); + return Ok(()); } - Ok(()) + // `.caos/agent.json` — checked-in repository instructions for the agent — + // is the one entry a workspace may legitimately carry at rest; the rest of + // `.caos` is harness state. + let entries = t.git_capture(&["ls-tree", "--name-only", &format!("{root}:.caos")], None)?; + let reserved: Vec<&str> = entries + .lines() + .filter(|name| !name.is_empty() && *name != AGENT_CONFIG_NAME) + .collect(); + if reserved.is_empty() { + return Ok(()); + } + Err(format!( + "the {what} contains reserved top-level .caos state ({}); \ + only .caos/{AGENT_CONFIG_NAME} may be checked in", + reserved.join(", ") + )) } /// Persist a message without taking ownership of the surrounding turn @@ -2035,6 +2052,9 @@ fn workspace_diff( ) -> Result { validate_hash(base_commit, "conversation workspace base")?; validate_hash(head, "conversation workspace head")?; + // Hide the transient merge-conflict record, but not `.caos/agent.json`: + // an edit to the checked-in repository instructions is a real workspace + // change that publishes with the conversation. let patch = t.git_capture( &[ "diff", @@ -2044,7 +2064,7 @@ fn workspace_diff( head, "--", ".", - ":(exclude).caos", + ":(exclude).caos/conflicts", ], None, )?; @@ -4978,6 +4998,52 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn reserved_caos_permits_only_checked_in_agent_instructions() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "caos-chat-agent-config-{}-{unique}", + std::process::id() + )); + let remote = root.join("remote.git"); + let repo = root.join("client"); + std::fs::create_dir_all(&remote).unwrap(); + test_git(&remote, &["init", "--quiet", "--bare"]); + std::fs::create_dir_all(&repo).unwrap(); + test_git(&repo, &["init", "--quiet"]); + configure_test_repo(&repo, "Alice"); + test_git( + &repo, + &["remote", "add", CAOS_REMOTE, remote.to_str().unwrap()], + ); + let transport = GitTransport::discover(&repo).unwrap(); + + std::fs::create_dir_all(repo.join(".caos")).unwrap(); + std::fs::write( + repo.join(".caos/agent.json"), + "{\"instructions\":\"run the tests\"}\n", + ) + .unwrap(); + std::fs::write(repo.join("workspace"), "base\n").unwrap(); + test_git(&repo, &["add", "."]); + test_git(&repo, &["commit", "--quiet", "-m", "instructions only"]); + let instructions_only = test_git(&repo, &["rev-parse", "HEAD"]); + reject_reserved_caos(&transport, &instructions_only, "base tree").unwrap(); + + std::fs::write(repo.join(".caos/conflicts"), "").unwrap(); + test_git(&repo, &["add", "."]); + test_git(&repo, &["commit", "--quiet", "-m", "with harness state"]); + let with_conflicts = test_git(&repo, &["rev-parse", "HEAD"]); + let error = reject_reserved_caos(&transport, &with_conflicts, "base tree").unwrap_err(); + assert!(error.contains("conflicts"), "{error}"); + assert!(error.contains("agent.json may be checked in"), "{error}"); + + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn workspace_proposal_three_way_merge_preserves_both_sides() { let unique = std::time::SystemTime::now() diff --git a/std/llm-step/src/main.rs b/std/llm-step/src/main.rs index 74c577c6..f4477af2 100644 --- a/std/llm-step/src/main.rs +++ b/std/llm-step/src/main.rs @@ -24,13 +24,17 @@ use conversation_protocol::ObjectId; use llm_client::{post_messages, DEFAULT_BASE_URL}; use serde_json::{json, Value}; use worker_common::{ - arg, caos, caos_curry, caos_recurry, cas_hash, eval_then_catching, forward, link, - own_args_tree, path, read_arg, read_arg_opt, read_commit, run_then_catching, run_worker, + arg, caos, caos_curry, caos_recurry, cas_hash, entries, eval_then_catching, file_name, forward, + link, own_args_tree, path, read_arg, read_arg_opt, read_commit, run_then_catching, run_worker, scratch, secret, write_commit_as, Arg, }; const AGENT_AUTHOR: &str = "caos-agent"; const STEP_DIR: &str = ".caos"; +/// Checked-in repository instructions for the agent (SPEC, "Repository agent +/// instructions"). The one `.caos` entry a workspace may legitimately carry at +/// rest; everything else under `.caos` is harness state. +pub(crate) const AGENT_CONFIG: &str = "agent.json"; /// The per-round output-token cap sent to the API. A single response is /// unlikely to need this much; when one does, `stop_reason: "max_tokens"` @@ -148,10 +152,8 @@ fn start(cfg: &Config) -> Result<(), String> { )); } let (ws, _) = canonical_workspace(&log)?; - if log.events.len() <= 2 && Path::new(&ws).join(STEP_DIR).exists() { - return Err(format!( - "the conversation's base tree already contains the reserved {STEP_DIR:?} entry" - )); + if log.events.len() <= 2 { + reject_reserved_base(&ws)?; } resume_run(cfg, &run, &head_hash, log) } @@ -591,6 +593,82 @@ fn parse_commit_timestamp(text: &str) -> Result { .map_err(|error| format!("commit has an invalid committer timestamp: {error}")) } +/// Refuse a conversation base whose `.caos` carries anything beyond the +/// checked-in `agent.json`: the rest of that directory is harness state and a +/// base already holding some would make it ambiguous. +fn reject_reserved_base(ws: &str) -> Result<(), String> { + let dir = format!("{ws}/{STEP_DIR}"); + if !Path::new(&dir).exists() { + return Ok(()); + } + caos(["get", &dir])?; + let reserved: Vec = entries(&dir)? + .iter() + .map(|entry| file_name(entry)) + .filter(|name| name != AGENT_CONFIG) + .collect(); + if reserved.is_empty() { + return Ok(()); + } + Err(format!( + "the conversation's base tree carries reserved {STEP_DIR:?} state ({}); \ + only {STEP_DIR}/{AGENT_CONFIG} may be checked in", + reserved.join(", ") + )) +} + +/// Read the workspace's `{STEP_DIR}/{AGENT_CONFIG}` instructions, if any. +/// Reread from the CURRENT workspace every round, like the tool registry, so +/// an agent that edits the file sees the change on its next request. +fn agent_instructions(ws: &str) -> Result, String> { + let dir = format!("{ws}/{STEP_DIR}"); + if !Path::new(&dir).exists() { + return Ok(None); + } + caos(["get", &dir])?; + let file = format!("{dir}/{AGENT_CONFIG}"); + if !Path::new(&file).exists() { + return Ok(None); + } + caos(["get", &file])?; + let text = fs::read_to_string(&file) + .map_err(|error| format!("reading {STEP_DIR}/{AGENT_CONFIG}: {error}"))?; + parse_agent_instructions(&text).map(Some) +} + +/// Parse `.caos/agent.json`: an object whose `instructions` string is the +/// repository's standing guidance to the agent (build/test commands, +/// conventions, publication requirements). Unknown fields are ignored so an +/// older harness keeps working when the schema grows; a malformed file fails +/// the round loudly rather than silently running without the repository's +/// instructions. +fn parse_agent_instructions(text: &str) -> Result { + let value: Value = serde_json::from_str(text) + .map_err(|error| format!("{STEP_DIR}/{AGENT_CONFIG} is not valid JSON: {error}"))?; + value["instructions"] + .as_str() + .map(str::to_string) + .ok_or_else(|| { + format!("{STEP_DIR}/{AGENT_CONFIG} must carry a string `instructions` field") + }) +} + +/// The system prompt for one round: the curried system plus the workspace's +/// checked-in repository instructions. +fn round_system(cfg: &Config, ws: &str) -> Result { + let instructions = agent_instructions(ws)?; + Ok(compose_system(&cfg.system, instructions.as_deref())) +} + +fn compose_system(system: &str, instructions: Option<&str>) -> String { + match instructions { + Some(instructions) => format!( + "{system}\n\nRepository instructions ({STEP_DIR}/{AGENT_CONFIG}):\n{instructions}" + ), + None => system.to_string(), + } +} + /// One LLM API round over `messages`. `prev` is the exact canonical head used /// to build the request; publication is conditional on that head so a response /// can never claim to have seen a concurrent interjection. @@ -612,7 +690,7 @@ fn llm_round( // sniffing per-model capabilities here would rot. "thinking": {"type": "adaptive"}, "cache_control": {"type": "ephemeral"}, - "system": cfg.system, + "system": round_system(cfg, ws)?, "tools": registry(cfg, ws)?, "messages": messages, }); @@ -2586,6 +2664,39 @@ committer agent 1700000123 +0000\n\nmessage\n"; ); } + #[test] + fn agent_config_yields_its_instructions_or_a_clear_error() { + assert_eq!( + parse_agent_instructions(r#"{"instructions":"Build with nix; test with cargo."}"#) + .unwrap(), + "Build with nix; test with cargo." + ); + // Unknown fields ride along so the schema can grow without breaking + // an older harness baked into a worker image. + assert_eq!( + parse_agent_instructions(r#"{"instructions":"x","future":{"a":1}}"#).unwrap(), + "x" + ); + assert!(parse_agent_instructions("{}") + .unwrap_err() + .contains("instructions")); + assert!(parse_agent_instructions(r#"{"instructions":42}"#) + .unwrap_err() + .contains("instructions")); + assert!(parse_agent_instructions("not json") + .unwrap_err() + .contains("valid JSON")); + } + + #[test] + fn repository_instructions_extend_the_curried_system_prompt() { + assert_eq!(compose_system("base prompt", None), "base prompt"); + assert_eq!( + compose_system("base prompt", Some("Run the fast checks.")), + "base prompt\n\nRepository instructions (.caos/agent.json):\nRun the fast checks." + ); + } + #[test] fn result_ids_are_scoped_to_their_round() { let run = "b".repeat(40); diff --git a/std/llm-step/src/subagents.rs b/std/llm-step/src/subagents.rs index 43a03228..6e8d2010 100644 --- a/std/llm-step/src/subagents.rs +++ b/std/llm-step/src/subagents.rs @@ -120,6 +120,17 @@ fn clean_agent_base(ws: &str, wc: &str) -> Result<(String, String), String> { let name = file_name(&entry); if name != STEP_DIR { link(&entry, clean.join(name))?; + continue; + } + // Keep the checked-in repository instructions with the workspace; the + // rest of `.caos` is this conversation's harness state, which the + // subagent must not inherit. + caos(["get", path(&entry)])?; + let config = entry.join(crate::AGENT_CONFIG); + if config.exists() { + let dir = clean.join(STEP_DIR); + fs::create_dir(&dir).map_err(|error| format!("creating {}: {error}", dir.display()))?; + link(&config, dir.join(crate::AGENT_CONFIG))?; } } let clean_tree = fresh("agent-workspace"); diff --git a/std/llm-step/src/tools.rs b/std/llm-step/src/tools.rs index a6bfdc12..85f5a510 100644 --- a/std/llm-step/src/tools.rs +++ b/std/llm-step/src/tools.rs @@ -901,7 +901,8 @@ fn components_opt(call: &Value, key: &str) -> Result, Fail> { /// Validate and split a workspace-relative path argument. A leading `/` is /// tolerated (treated as the workspace root); `..` and the reserved `.caos` -/// are refused. +/// are refused — except `.caos/conflicts` (the merge conflict set) and +/// `.caos/agent.json` (checked-in repository instructions). fn components(call: &Value, key: &str) -> Result, Fail> { let raw = call["input"][key] .as_str() @@ -919,10 +920,13 @@ fn components(call: &Value, key: &str) -> Result, Fail> { if comps.iter().any(|c| c == "..") { return Err(User("`..` is not allowed in workspace paths".to_string())); } - if comps[0] == STEP_DIR && !(comps.len() == 2 && comps[1] == "conflicts") { + if comps[0] == STEP_DIR + && !(comps.len() == 2 && (comps[1] == "conflicts" || comps[1] == crate::AGENT_CONFIG)) + { return Err(User(format!( "{STEP_DIR}/ is reserved for the harness; only {STEP_DIR}/conflicts (the merge \ - conflict set) is editable" + conflict set) and {STEP_DIR}/{} (repository instructions) are editable", + crate::AGENT_CONFIG ))); } Ok(comps) @@ -1130,6 +1134,26 @@ mod tests { assert!(read["input_schema"].get("required").is_none()); } + #[test] + fn workspace_paths_reserve_caos_except_conflicts_and_agent_config() { + let split = + |path: &str| match components(&json!({"input": {"file_path": path}}), "file_path") { + Ok(comps) => comps, + Err(User(message)) | Err(Infra(message)) => { + panic!("{path} must be allowed: {message}") + } + }; + assert_eq!(split("src/main.rs"), ["src", "main.rs"]); + assert_eq!(split(".caos/conflicts"), [".caos", "conflicts"]); + assert_eq!(split(".caos/agent.json"), [".caos", "agent.json"]); + for refused in [".caos", ".caos/step.json", ".caos/agent.json/nested"] { + match components(&json!({"input": {"file_path": refused}}), "file_path") { + Err(User(message)) => assert!(message.contains("reserved"), "{message}"), + Ok(_) | Err(Infra(_)) => panic!("{refused} must be refused as a user error"), + } + } + } + #[test] fn arg_lines_parse() { let required = parse_arg("hash The record hash.").unwrap(); diff --git a/tests/agent-instructions/.caos-expr b/tests/agent-instructions/.caos-expr new file mode 100644 index 00000000..667ffc6a --- /dev/null +++ b/tests/agent-instructions/.caos-expr @@ -0,0 +1,12 @@ +# tests/agent-instructions, as an ENTRY (design/caos-expr.md, Phase 3): +# evaluating this directory yields the test's ARG TREE, which dev/run-test +# then runs. +# +# A CLIENT TEST — it drives `caos-cli`, so its base is `DEEP-DEPS/cli-test` +# (dev/cli-test), the image that stages a git worktree and then runs `worker1`. +# +# --test this directory itself, staged into the repo as ./test with its +# DEEP-DEPS alongside. `--test:@=.` is inert by construction: +# `strip_caos_expr` removes this file from the tree before the +# expression is evaluated against it, so nothing recurses. +curry --base:@=DEEP-DEPS/cli-test --worker1:@=worker.sh --test:@=. diff --git a/tests/agent-instructions/DEPS b/tests/agent-instructions/DEPS new file mode 100644 index 00000000..1d47b662 --- /dev/null +++ b/tests/agent-instructions/DEPS @@ -0,0 +1,26 @@ +# What this test reaches for that no DEPS edge supplies (format +# ` `). deep-deps mounts each of these RECURSIVELY +# DEEPENED at DEEP-DEPS/, so a std entry brings its own deps +# (std//DEPS) with it, and only what an edge CANNOT carry is +# listed here (design/caos-expr.md): +# +# THE GIT CLOSURE COVERS TREE REFERENCES AND NOTHING ELSE. +# +# - A CURRY'S BASE is a BLOB holding the base image's hash, not a +# tree reference: naming `deep-deps` still means naming `runner`. +# - A HASH BOUND AS A LITERAL is likewise a blob: rustc's seed binds +# `--cargo=`, so anything rustc builds still means `cargo`. +# +# Undeclared is UNAVAILABLE, deliberately: a missing line fails the +# test loudly rather than letting it borrow something ambient. An +# unnecessary line only over-keys the test, so when in doubt, declare. +../../std/bash-tool bash-tool +../../std/llm-step llm-step +../../std/rgrep rgrep +../../std/cargo cargo +../../std/bash bash +../../std/flake-builder flake-builder +../../std/llm-stub llm-stub +# The image this client test runs in, with the client-repo setup as its +# /worker: a dependency, not a harness. +../../dev/cli-test cli-test diff --git a/tests/agent-instructions/worker.sh b/tests/agent-instructions/worker.sh new file mode 100755 index 00000000..0cf916c9 --- /dev/null +++ b/tests/agent-instructions/worker.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Checked-in repository instructions (.caos/agent.json — SPEC "Repository +# agent instructions"). A base tree carrying only .caos/agent.json must be +# accepted, and the harness must fold the file's `instructions` into the +# system prompt it sends the model; any other .caos entry beside it must +# still be refused before admission. +set -euo pipefail + +fail() { echo "FAIL: $*" >&2; exit 1; } +commit() { git add -A && git -c user.email=test@caos -c user.name=caos commit -qm "$1"; } +mkcommit() { # [parent] + local tree=$1 message=$2 parent=${3:-} + local parents=() + if [ -n "$parent" ]; then parents=(-p "$parent"); fi + git -c user.email=test@caos -c user.name=caos \ + commit-tree "$tree" "${parents[@]}" -m "$message" +} +remote_tip() { # + local lines + lines=$(git ls-remote --refs caos "$1") || return 1 + [ -n "$lines" ] || return 1 + printf '%s\n' "${lines%%[[:space:]]*}" +} + +echo "== stage fixture and scripted LLM ==" >&2 +"$CAOS_CLI" get DEEP-DEPS/llm-stub /tmp/llm-stub-entry \ + || fail "resolving std/llm-stub" +stub_bin=/tmp/llm-stub-bin +install -m 755 /tmp/llm-stub-entry/bin/llm-stub "$stub_bin" + +INSTRUCTIONS="Repo rule: mention the word shibboleth when you finish." +mkdir -p ws/.caos +echo "hello workspace" > ws/file.txt +printf '{"instructions":"%s","future-field":true}\n' "$INSTRUCTIONS" > ws/.caos/agent.json +mkdir -p ws-bad/.caos +cp ws/.caos/agent.json ws-bad/.caos/agent.json +echo reserved > ws-bad/.caos/marker +echo "hello workspace" > ws-bad/file.txt +commit "agent-instructions fixture" +git config user.name tester +git config user.email tester@example.com +base=$(mkcommit "HEAD:ws" "instructed base") +badbase=$(mkcommit "HEAD:ws-bad" "over-reserved base") + +mkdir -p .caos-secrets +printf '.caos-secrets/\n' >> .git/info/exclude +printf '%s\n' \ + 'name=anthropic-api-key' \ + 'value=test-key' \ + 'entropy=0123456789abcdef0123456789abcdef' \ + 'reader=DEEP-DEPS/llm-step' \ + > .caos-secrets/anthropic-api-key + +mkdir stub +stub_pid="" +cleanup() { + if [ -n "$stub_pid" ]; then kill "$stub_pid" 2>/dev/null || true; fi +} +trap cleanup EXIT + +for _ in 1 2 3 4 5; do + port=$((20000 + RANDOM % 20000)) + "$stub_bin" "0.0.0.0:$port" "$PWD/stub" 2>stub/log & + stub_pid=$! + ready=0 + for _ in {1..400}; do + if ! kill -0 "$stub_pid" 2>/dev/null; then break; fi + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + ready=1 + break + fi + sleep 0.005 + done + if [ "$ready" = 1 ]; then break; fi + kill "$stub_pid" 2>/dev/null || true + wait "$stub_pid" 2>/dev/null || true + stub_pid="" +done +[ -n "$stub_pid" ] || fail "could not start llm-stub: $(cat stub/log)" + +test_id="$(date +%s%N)-$$-$RANDOM" +conv="${test_id}-instructed" +bad_conv="${test_id}-over-reserved" +stub_host=${CAOS_STUB_HOST:-host.containers.internal} +opts=(--model test-model --base-url "http://$stub_host:$port") + +echo "== a .caos entry beside agent.json is still refused ==" >&2 +if "$CAOS_CLI" chat "$bad_conv" -m "hello" --base "$badbase" "${opts[@]}" 2>base.err; then + fail "chat accepted a base with reserved .caos state beside agent.json" +fi +grep -q "\.caos" base.err || fail "reserved-base error is unclear: $(cat base.err)" +grep -q "marker" base.err || fail "reserved-base error does not name the entry" +if remote_tip "refs/caos/v2/conversations/$bad_conv/head" >/dev/null; then + fail "reserved-base failure created a conversation" +fi +[ ! -e stub/request-1.json ] || fail "reserved-base failure reached the LLM" + +echo "== agent.json instructions reach the system prompt ==" >&2 +printf '{"content":[{"text":"noted, shibboleth","type":"text"}],"stop_reason":"end_turn"}' \ + > stub/response-1.json +"$CAOS_CLI" chat "$conv" -m "read the repo rules" --base "$base" "${opts[@]}" \ + >chat.out 2>chat.err || fail "instructed turn failed: $(cat chat.err)" +[ -e stub/request-1.json ] || fail "the turn never reached the LLM" +jq -e --arg wanted "$INSTRUCTIONS" '.system | contains($wanted)' \ + stub/request-1.json >/dev/null \ + || fail "system prompt is missing the repository instructions: $(jq -r '.system' stub/request-1.json)" +jq -e '.system | contains("Repository instructions (.caos/agent.json)")' \ + stub/request-1.json >/dev/null \ + || fail "system prompt does not attribute the repository instructions" +jq -e '.system | startswith("You are a coding agent")' stub/request-1.json >/dev/null \ + || fail "repository instructions replaced the curried system prompt instead of extending it" + +echo "== the published head keeps the checked-in agent.json ==" >&2 +tip=$(remote_tip "refs/caos/v2/conversations/$conv/head") \ + || fail "instructed conversation has no head" +git fetch -q caos "$tip" +[ "$(git show "$tip:.caos/agent.json" | jq -r '.instructions')" = "$INSTRUCTIONS" ] \ + || fail "the conversation head lost .caos/agent.json" +[ "$(git show "$tip:file.txt")" = "hello workspace" ] \ + || fail "the conversation head lost its base workspace" + +echo "agent-instructions: ALL PASS" >&2