diff --git a/epiphany-openai-runtime/src/lib.rs b/epiphany-openai-runtime/src/lib.rs index 8f024ad0..f436328c 100644 --- a/epiphany-openai-runtime/src/lib.rs +++ b/epiphany-openai-runtime/src/lib.rs @@ -593,11 +593,11 @@ pub fn build_worker_model_request( let mut instructions = worker_instructions(launch_request, &launch_document, &output_schema_json); if launch_request.binding_id == epiphany_core::EPIPHANY_VERIFICATION_ROLE_BINDING_ID { - instructions.push_str("\n\nTool mandate: before returning `needs-evidence` because source files, command artifacts, commit diffs, or Hands receipt bodies are not inspectable, call the read-only source tools available on this request. Use `mcp__epiphany_source__read_file` for cited source/artifact paths, `mcp__epiphany_source__git_show` for commit diffs, and `mcp__epiphany_source__read_hands_receipt` for Hands patch/command/commit receipts. If a tool fails, cite that failed tool result and the exact remaining blocker."); + instructions.push_str("\n\nTool mandate: before returning `needs-evidence` because source files, artifact directories, command artifacts, commit diffs, or Hands receipt bodies are not inspectable, call the read-only source tools available on this request. Use `mcp__epiphany_source__read_file` for cited source/artifact files, `mcp__epiphany_source__directory_inventory` for bounded workspace directory counts and bytes, `mcp__epiphany_source__git_show` for commit diffs, and `mcp__epiphany_source__read_hands_receipt` for Hands patch/command/commit receipts. Directory totals are authoritative only when the tool reports `complete=true`. If a tool fails, cite that failed tool result and the exact remaining blocker."); } else if launch_request.binding_id == epiphany_core::EPIPHANY_RESEARCH_ROLE_BINDING_ID { - instructions.push_str("\n\nTool mandate: Eyes must inspect current repository sources before emitting evidence. Call the bounded read-only file or Git tools, cite the exact inspected paths/revisions in filesInspected and evidence, and report a source gap if the required body cannot be observed."); + instructions.push_str("\n\nTool mandate: Eyes must inspect current repository sources before emitting evidence. Call the bounded read-only file, directory-inventory, or Git tools, cite the exact inspected paths/revisions in filesInspected and evidence, and report a source gap if the required body cannot be observed. Directory totals are authoritative only when the inventory reports `complete=true`."); } else if launch_request.binding_id == epiphany_core::EPIPHANY_MODELING_ROLE_BINDING_ID { - instructions.push_str("\n\nTool mandate: Modeling must inspect current repository sources before proposing repository anatomy. Call the bounded read-only file or Git tools, cite the exact inspected paths/revisions in filesInspected and evidence, and emit regather-needed instead of inventing unobserved structure."); + instructions.push_str("\n\nTool mandate: Modeling must inspect current repository sources before proposing repository anatomy. Call the bounded read-only file, directory-inventory, or Git tools, cite the exact inspected paths/revisions in filesInspected and evidence, and emit regather-needed instead of inventing unobserved structure. Directory totals are authoritative only when the inventory reports `complete=true`."); } let mut request = EpiphanyModelRequest::new( request_id, @@ -1106,6 +1106,22 @@ fn repository_source_tools() -> Vec { }) .to_string(), }, + EpiphanyModelToolDefinition { + name: "mcp__epiphany_source__directory_inventory".to_string(), + description: "Measure a workspace-confined directory with deterministic bounded counts, regular-file bytes, and path samples. Totals are usable as complete evidence only when complete=true; symlinks are counted but never followed.".to_string(), + parameters_json: serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "path": {"type": "string"}, + "maxDepth": {"type": "integer", "minimum": 0, "maximum": 8}, + "maxEntries": {"type": "integer", "minimum": 1, "maximum": 4096}, + "maxSamples": {"type": "integer", "minimum": 1, "maximum": 100} + }, + "required": ["path"] + }) + .to_string(), + }, EpiphanyModelToolDefinition { name: "mcp__epiphany_source__git_show".to_string(), description: "Read a bounded git show/diff preview for a commit or revision in the current workspace.".to_string(), @@ -2822,6 +2838,10 @@ mod tests { .tools .iter() .any(|tool| tool.name == "mcp__epiphany_source__read_file")); + assert!(model_request + .tools + .iter() + .any(|tool| tool.name == "mcp__epiphany_source__directory_inventory")); assert!(model_request.instructions.contains("Modeling must inspect")); let openai_summary = EpiphanyOpenAiRuntimeRunSummary { store: store.display().to_string(), @@ -2970,11 +2990,15 @@ mod tests { .collect::>(); assert!(tool_names.contains(&"mcp__epiphany_source__read_file")); + assert!(tool_names.contains(&"mcp__epiphany_source__directory_inventory")); assert!(tool_names.contains(&"mcp__epiphany_source__git_show")); assert!(tool_names.contains(&"mcp__epiphany_source__read_hands_receipt")); assert!(model_request .instructions .contains("mcp__epiphany_source__read_file")); + assert!(model_request + .instructions + .contains("mcp__epiphany_source__directory_inventory")); Ok(()) } diff --git a/epiphany-tool-mcp-runtime/src/native_source.rs b/epiphany-tool-mcp-runtime/src/native_source.rs index 69d1d2e8..70c19957 100644 --- a/epiphany-tool-mcp-runtime/src/native_source.rs +++ b/epiphany-tool-mcp-runtime/src/native_source.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result, anyhow}; use epiphany_tool_adapter::EpiphanyToolInvocationIntent; use serde_json::{Value, json}; -use std::fs::File; +use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -19,12 +19,114 @@ pub fn execute_epiphany_source( } match intent.tool_name.as_str() { "read_file" => read_file(cwd, &arguments), + "directory_inventory" => directory_inventory(cwd, &arguments), "git_show" => git_show(cwd, &arguments), "read_hands_receipt" => read_hands_receipt(store, &arguments), other => Err(anyhow!("unknown epiphany_source tool {other:?}")), } } +fn directory_inventory(cwd: &Path, arguments: &Value) -> Result { + let requested = required_string(arguments, "path")?; + let maximum_depth = optional_u64(arguments, "maxDepth")? + .unwrap_or(3) + .clamp(0, 8) as usize; + let maximum_entries = optional_u64(arguments, "maxEntries")? + .unwrap_or(1_024) + .clamp(1, 4_096) as usize; + let maximum_samples = optional_u64(arguments, "maxSamples")? + .unwrap_or(40) + .clamp(1, 100) as usize; + let root = confined_path(cwd, requested)?; + if !root.is_dir() { + return Err(anyhow!("directory inventory path is not a directory")); + } + + let mut pending = vec![(root.clone(), 0usize)]; + let mut entries = Vec::new(); + let mut entry_limit_hit = false; + let mut depth_limit_hit = false; + while let Some((directory, depth)) = pending.pop() { + let mut children = fs::read_dir(&directory) + .with_context(|| format!("reading directory {}", directory.display()))? + .collect::>>()?; + children.sort_by_key(|entry| entry.file_name()); + for child in children { + if entries.len() == maximum_entries { + entry_limit_hit = true; + break; + } + let path = child.path(); + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("reading metadata for {}", path.display()))?; + let kind = if metadata.file_type().is_symlink() { + "symlink" + } else if metadata.is_dir() { + "directory" + } else if metadata.is_file() { + "file" + } else { + "other" + }; + entries.push((path.clone(), kind, metadata.len())); + if kind == "directory" && depth < maximum_depth { + pending.push((path, depth + 1)); + } else if kind == "directory" + && fs::read_dir(&path) + .with_context(|| format!("testing directory depth at {}", path.display()))? + .next() + .transpose()? + .is_some() + { + depth_limit_hit = true; + } + } + if entry_limit_hit { + break; + } + pending.sort_by(|left, right| right.0.cmp(&left.0)); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + + let file_count = entries.iter().filter(|entry| entry.1 == "file").count(); + let directory_count = entries + .iter() + .filter(|entry| entry.1 == "directory") + .count(); + let symlink_count = entries.iter().filter(|entry| entry.1 == "symlink").count(); + let total_file_bytes = entries + .iter() + .filter(|entry| entry.1 == "file") + .map(|entry| entry.2) + .sum::(); + let sample = entries + .iter() + .take(maximum_samples) + .map(|(path, kind, bytes)| { + json!({ + "path": path.strip_prefix(&root).unwrap_or(path).display().to_string(), + "kind": kind, + "bytes": if *kind == "file" { Some(*bytes) } else { None }, + }) + }) + .collect::>(); + let complete = !entry_limit_hit && !depth_limit_hit; + Ok(json!({ + "path": root.display().to_string(), + "maxDepth": maximum_depth, + "maxEntries": maximum_entries, + "complete": complete, + "entryLimitHit": entry_limit_hit, + "depthLimitHit": depth_limit_hit, + "entryCount": entries.len(), + "fileCount": file_count, + "directoryCount": directory_count, + "symlinkCount": symlink_count, + "totalFileBytes": total_file_bytes, + "sample": sample, + })) +} + fn read_file(cwd: &Path, arguments: &Value) -> Result { let requested = required_string(arguments, "path")?; let start = optional_u64(arguments, "startLine")?.unwrap_or(1).max(1) as usize; @@ -230,4 +332,71 @@ mod tests { assert_eq!(read_retained(&b"abcdef"[..], 3)?, "abc\n..."); Ok(()) } + + #[test] + fn inventories_workspace_directory_with_deterministic_totals() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::create_dir(dir.path().join("artifacts"))?; + fs::create_dir(dir.path().join("artifacts/pulse-2"))?; + fs::create_dir(dir.path().join("artifacts/pulse-1"))?; + fs::write(dir.path().join("artifacts/pulse-1/a.txt"), b"awake")?; + fs::write(dir.path().join("artifacts/pulse-2/b.txt"), b"machine")?; + let value = execute_epiphany_source( + &intent( + "directory_inventory", + r#"{"path":"artifacts","maxDepth":2,"maxEntries":10,"maxSamples":10}"#, + ), + dir.path(), + dir.path(), + )?; + assert_eq!(value["complete"], true); + assert_eq!(value["entryCount"], 4); + assert_eq!(value["directoryCount"], 2); + assert_eq!(value["fileCount"], 2); + assert_eq!(value["totalFileBytes"], 12); + assert_eq!(value["sample"][0]["path"], "pulse-1"); + assert!( + execute_epiphany_source( + &intent("directory_inventory", r#"{"path":"../escape"}"#), + dir.path(), + dir.path(), + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn directory_inventory_reports_truncation_instead_of_partial_truth() -> Result<()> { + let dir = tempfile::tempdir()?; + fs::create_dir(dir.path().join("artifacts"))?; + for name in ["a", "b", "c"] { + fs::write(dir.path().join("artifacts").join(name), name)?; + } + let value = execute_epiphany_source( + &intent( + "directory_inventory", + r#"{"path":"artifacts","maxEntries":2}"#, + ), + dir.path(), + dir.path(), + )?; + assert_eq!(value["complete"], false); + assert_eq!(value["entryCount"], 2); + + fs::create_dir(dir.path().join("nested"))?; + fs::create_dir(dir.path().join("nested/child"))?; + fs::write(dir.path().join("nested/child/body"), b"hidden by depth")?; + let depth_limited = execute_epiphany_source( + &intent( + "directory_inventory", + r#"{"path":"nested","maxDepth":0,"maxEntries":10}"#, + ), + dir.path(), + dir.path(), + )?; + assert_eq!(depth_limited["complete"], false); + assert_eq!(depth_limited["depthLimitHit"], true); + Ok(()) + } } diff --git a/state/scratch.md b/state/scratch.md index 5df1c83e..e1d2d274 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -4,24 +4,39 @@ Disposable working memory for one bounded rite. ## Current Subgoal -Resume autonomous cross-organ turnover from copied c006 state now that release -iteration is no longer the active machine fault. +Give Eyes, Modeling, and Soul a governed read-only directory inventory so they +can source-ground artifact-retention claims instead of trusting operator prose. -## Accepted boundary +## Live finding -Exact `aa841873` makes legacy coordinator repair one full-snapshot transaction. -The full core suite passes 670 tests with one intentional ignore. Its genuine -core-source package completed in 37.27s wall/Cargo 29.54s versus the 5m02s -baseline; identical replay took 8.65s wall/Cargo 0.69s. Both produced exact -24-binary release -`sha256-dd4f05cb2e7f1b26156e7c9d5ed58f6d97ce5808d123b1bec343bfac59bf2ce7` -and witness -`sha256-793b29fcb7af4f8314a46f0fe0013cc8f5f382e7490a32c160385d29359adca5`. +Exact `aa841873` on copied c006 accepted one fresh user proposal and issued one +proposal-bound Modeling grant. Self held `awaiting-fulfillment`, then completed +once at revision 46 with zero retry. Runtime ended at 48 jobs/results, zero +open jobs, and no Hands/Eyes frontier. Modeling used five governed source calls +and correctly refused the operator-supplied `72 directories / 4.2 MiB` claim: +the native source adapter could read files and Git objects but could not +enumerate or measure `.epiphany-smoke/heartbeat-c006-c9a2a979`. The copied brake +was re-engaged and both direct PID containers exited zero. Actual c006 was not +touched. -## Next boundary +## Authority map -Keep actual c006 alive and cognitively braked. Inspect operator-safe current -frontier state, then choose a copied-state turn that extends Self, Eyes, -Modeling, or Mind coordination without minting Hands, Persona, or deployment -authority. Separately recheck the Yggdrasil credential boundary before any -public Persona consequence. Yggdrasil never builds. +- Owner: the native `epiphany_source` adapter owns bounded read-only workspace + observation. +- Inputs: one canonical workspace-confined directory plus explicit depth, + entry, and sample ceilings. +- Outputs: deterministic entry/file/directory/symlink counts, total regular-file + bytes, bounded samples, and explicit entry/depth truncation state. +- Derived state: samples are display only. Totals are admissible evidence only + when `complete=true`. +- Forbidden writers: shell execution, symlink traversal, outside-workspace + reads, mutation, operator counts, and Heartbeat-specific policy. +- Shared paths: Eyes, Modeling, and Soul receive the same model tool definition, + native execution path, and typed intent/receipt boundary. +- Cut line: add one general `directory_inventory` beside `read_file`; do not + add a special Heartbeat inspector or teach prompts to trust proposal prose. +- Verification: focused native execution tests must prove exact totals, + deterministic ordering, confinement, and explicit truncation; OpenAI request + tests must prove the tool and completeness mandate are visible to workers. + Then package and replay the copied proposal with the original operator counts + omitted so Epiphany itself measures the artifact family.