Skip to content
Closed
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
4 changes: 3 additions & 1 deletion harness/prompts/cli.txt
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,9 @@ served by the shell worker (no separate install). Confirm they are available wit
`iii trigger engine::functions::list --json '{"prefix":"coder::"}'`. Its functions include `coder::read-file`,
`coder::search`, `coder::list-folder`, `coder::tree`, `coder::create-file`,
`coder::update-file`, `coder::move`, and `coder::delete-file` — the prefix check shows
the full inventory. Use `coder::move` for renames and moves, never delete-then-recreate. Plain
the full inventory. Use `coder::move` for renames and moves, never delete-then-recreate. New
files belong in your working directory by default — relative paths resolve against it; only
write to another location when the user names one. Plain
file browsing outside code work (like `shell::fs::ls`) is still fine. Fetch each contract
first, as always.

Expand Down
4 changes: 3 additions & 1 deletion harness/prompts/default.txt
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,9 @@ served by the shell worker (no separate install). Confirm they are available wit
`engine::functions::list { prefix: "coder::" }`. Its functions include `coder::read-file`,
`coder::search`, `coder::list-folder`, `coder::tree`, `coder::create-file`,
`coder::update-file`, `coder::move`, and `coder::delete-file` — the prefix check shows
the full inventory. Use `coder::move` for renames and moves, never delete-then-recreate. Plain
the full inventory. Use `coder::move` for renames and moves, never delete-then-recreate. New
files belong in your working directory by default — relative paths resolve against it; only
write to another location when the user names one. Plain
file browsing outside code work (like `shell::fs::ls`) is still fine. Fetch each contract
first, as always.

Expand Down
6 changes: 5 additions & 1 deletion harness/src/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1178,7 +1178,11 @@ async fn assemble_context(
fn with_filesystem_root_aid(system_prompt: Option<String>, record: &TurnRecord) -> Option<String> {
let mut lines = vec![format!("Your session id is {}.", record.session_id)];
if let Some(dir) = record.options.filesystem_root() {
lines.push(format!("Your working directory is {dir}."));
lines.push(format!(
"Your working directory is {dir}. Create new files there by default — \
relative paths resolve against it; only write elsewhere when the user \
names another location."
));
}
if let Some(aid) = policy_aid(record.options.functions.as_ref()) {
lines.push(aid);
Expand Down
2 changes: 1 addition & 1 deletion provider-anthropic/prompts/identity.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Lifecycle: `worker::list`, `worker::add` (registry or OCI: `{ source: { kind: "r

Nothing registered fits? Search the registry: `directory::registry::workers::list { search }` pages the catalogue; `directory::registry::workers::info { name }` → functions/config/dependencies, judge fit BEFORE installing. Both documented here, no contract fetch. Installing runs new code: say what/why → `worker::add { source: { kind: "registry", name: "<name>" } }` → confirm `engine::functions::list { prefix: "<worker>::" }` → fetch the contracts you will use in ONE `engine::functions::info { function_ids: [...] }` call (registry detail = preview, not contract). No `directory::*`? `worker::start` an installed-but-stopped directory worker from `worker::list`, else `worker::add { source: { kind: "registry", name: "iii-directory" } }`; registry unreachable → say so, use what's registered.

Code-file create/edit/move/delete → `coder::*` (shell worker); never improvise edits via `shell::exec`. Inventory = `engine::functions::list { prefix: "coder::" }`: `coder::read-file`, `coder::search`, `coder::list-folder`, `coder::tree`, `coder::create-file`, `coder::update-file`, `coder::move`, `coder::delete-file` among them — fetch the contracts you need in ONE batched call before first use; they stay valid all session. Renames/moves = `coder::move`, never delete-then-recreate. Generic browsing (`shell::fs::ls`) outside a code task is fine; once code files are touched, coder owns file ops.
Code-file create/edit/move/delete → `coder::*` (shell worker); never improvise edits via `shell::exec`. Inventory = `engine::functions::list { prefix: "coder::" }`: `coder::read-file`, `coder::search`, `coder::list-folder`, `coder::tree`, `coder::create-file`, `coder::update-file`, `coder::move`, `coder::delete-file` among them — fetch the contracts you need in ONE batched call before first use; they stay valid all session. Renames/moves = `coder::move`, never delete-then-recreate. New files land in your working directory by default — relative paths resolve against it; write elsewhere only when the user names a location. Generic browsing (`shell::fs::ls`) outside a code task is fine; once code files are touched, coder owns file ops.

SDK authoring: construct exactly ONE symbol, `registerWorker`; its RETURN value exposes `registerFunction`, `registerTrigger`, `trigger` as METHODS (`iii.registerFunction(...)`), NOT top-level exports — destructuring yields `undefined`, `TypeError: registerFunction is not a function`. Declare `description`, `request_format`, `response_format` on every registered function — they become the contract `engine::functions::info` serves. Inspect the runtime via `engine::workers::info { name }`; don't assume. BEFORE the first line of worker code (new worker OR new registrations), fetch the language's SDK reference as Markdown — from-memory SDK code gets signatures/config keys subtly wrong (a from-memory `registerTrigger` lands but never fires): https://iii.dev/docs/api-reference/sdk-node (Node/TypeScript), https://iii.dev/docs/api-reference/sdk-python (Python), https://iii.dev/docs/api-reference/sdk-rust (Rust), https://iii.dev/docs/api-reference/sdk-browser (browser), https://iii.dev/docs/sdk-reference/engine-sdk (raw WebSocket protocol, other languages). Append `.md` for raw markdown; fetch fails → index at https://iii.dev/docs/llms.txt; docs unreachable → say so, verify each registration with a real call. `engine::functions::info` stays the reference for CALLING — never fetch docs for an ordinary call.

Expand Down
4 changes: 3 additions & 1 deletion provider-openai-codex/prompts/identity.txt
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ and route file work through them — `coder::read-file`, `coder::search`,
`coder::list-folder`, `coder::tree`, `coder::create-file`, `coder::update-file`,
`coder::move`, `coder::delete-file` among them; the prefix list is the full inventory —
contracts via `engine::functions::info` first, as always. Renames and moves go through
`coder::move`, never delete-then-recreate. Generic browsing outside a code task (e.g.
`coder::move`, never delete-then-recreate. New files belong in your working directory by
default — relative paths resolve against it; write elsewhere only when the user names a
location. Generic browsing outside a code task (e.g.
`shell::fs::ls`) stays fine; within one, coder owns the file ops.

To author a worker: construct exactly one symbol from the SDK, `registerWorker`. Its RETURN
Expand Down
4 changes: 3 additions & 1 deletion provider-openai/prompts/identity.txt
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ and route file work through them — `coder::read-file`, `coder::search`,
`coder::move`, `coder::delete-file` among them; the prefix list is the full inventory —
fetch the contracts you need in ONE batched `engine::functions::info` call before first use,
reused for the rest of the session. Renames and moves go through
`coder::move`, never delete-then-recreate. Generic browsing outside a code task (e.g.
`coder::move`, never delete-then-recreate. New files belong in your working directory by
default — relative paths resolve against it; write elsewhere only when the user names a
location. Generic browsing outside a code task (e.g.
`shell::fs::ls`) stays fine; within one, coder owns the file ops.

To author a worker: construct exactly one symbol from the SDK, `registerWorker`. Its RETURN
Expand Down
4 changes: 3 additions & 1 deletion provider-xai/prompts/identity.txt
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ and route file work through them — `coder::read-file`, `coder::search`,
`coder::move`, `coder::delete-file` among them; the prefix list is the full inventory —
fetch the contracts you need in ONE batched `engine::functions::info` call before first use,
reused for the rest of the session. Renames and moves go through
`coder::move`, never delete-then-recreate. Generic browsing outside a code task (e.g.
`coder::move`, never delete-then-recreate. New files belong in your working directory by
default — relative paths resolve against it; write elsewhere only when the user names a
location. Generic browsing outside a code task (e.g.
`shell::fs::ls`) stays fine; within one, coder owns the file ops.

To author a worker: construct exactly one symbol from the SDK, `registerWorker`. Its RETURN
Expand Down
6 changes: 4 additions & 2 deletions shell/src/code/functions/create_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ pub struct CreateFileInput {

#[derive(Debug, Deserialize, JsonSchema)]
pub struct CreateFileSpec {
/// Path relative to the primary allowed root, or an absolute path inside
/// any allowed root. Call `coder::info` to see the allowed roots. Paths
/// Relative paths resolve against the session working directory when the
/// session is scoped (the default for console chats), otherwise against the
/// primary allowed root; an absolute path must sit inside an allowed root.
/// Call `coder::info` for the allowed roots and the session root. Paths
/// outside every allowed root are rejected — use the shell worker's
/// `shell::fs::*` for host paths outside the jail.
pub path: String,
Expand Down
11 changes: 6 additions & 5 deletions shell/src/code/functions/delete_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ use crate::code::path::PathResolver;
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(example = "example_delete_file_input")]
pub struct DeleteFileInput {
/// Paths to remove. Each entry is relative to the primary allowed root,
/// or an absolute path inside any allowed root. Call `coder::info` to
/// see the allowed roots. Paths outside every allowed root are rejected
/// — use the shell worker's `shell::fs::*` for host paths outside the
/// jail.
/// Paths to remove. Relative paths resolve against the session working
/// directory when the session is scoped (the default for console chats),
/// otherwise against the primary allowed root; an absolute path must sit
/// inside an allowed root. Call `coder::info` for the allowed roots and the
/// session root. Paths outside every allowed root are rejected — use the
/// shell worker's `shell::fs::*` for host paths outside the jail.
pub paths: Vec<String>,
/// Required for non-empty directories. Files and empty dirs ignore it.
#[serde(default)]
Expand Down
73 changes: 64 additions & 9 deletions shell/src/code/functions/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ use serde::{Deserialize, Serialize};
use crate::code::config::CoderConfig;
use crate::code::path::PathResolver;

/// No arguments — `coder::info` is a pure discovery call.
/// No caller arguments — `coder::info` is a pure discovery call. The harness
/// stamps the session filesystem scope internally.
// examples are wire-contract; goldens pin them.
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(example = "example_info_input")]
pub struct InfoInput {}
pub struct InfoInput {
/// Internal harness filesystem scope; omitted from published schema.
#[serde(default)]
#[schemars(skip)]
pub fs_scope: Option<crate::fs::FsScope>,
}

// examples are wire-contract; goldens pin them.
fn example_info_input() -> serde_json::Value {
Expand All @@ -32,9 +38,19 @@ pub struct InfoOutput {
pub base_paths: Vec<String>,

/// Convenience duplicate of `base_paths[0]` — the primary allowed root.
/// Relative paths resolve against this directory.
/// Relative paths resolve against this directory when the session is NOT
/// scoped; when `session_root` is present, they resolve against that
/// instead.
pub primary_root: String,

/// The session working directory this call is scoped to, when the harness
/// stamped one (the default for console chats). When present, relative
/// paths anchor HERE rather than at `primary_root`; new files land here by
/// default. A canonical absolute path inside one of `base_paths`. `None`
/// for an unscoped session — relative paths then anchor at `primary_root`.
#[serde(skip_serializing_if = "Option::is_none")]
pub session_root: Option<String>,

/// Glob patterns matched per root (root-relative). Files whose
/// root-relative path matches are listable but not
/// readable/writable/deletable/creatable; they return C211.
Expand Down Expand Up @@ -116,25 +132,36 @@ pub struct InfoOutput {
pub async fn handle(
resolver: Arc<PathResolver>,
cfg: Arc<CoderConfig>,
req: InfoInput,
) -> Result<InfoOutput, String> {
// info canonicalizes the configured roots (fs), so keep it off the executor
// for consistency with the other read handlers.
tokio::task::spawn_blocking(move || Ok(inner(&resolver, &cfg)))
.await
.map_err(|e| format!("info task join failed: {e}"))?
tokio::task::spawn_blocking(move || {
let scope_root = crate::fs::scope_root(req.fs_scope.as_ref());
Ok(inner(&resolver, &cfg, scope_root))
})
.await
.map_err(|e| format!("info task join failed: {e}"))?
}

fn inner(resolver: &PathResolver, cfg: &CoderConfig) -> InfoOutput {
fn inner(resolver: &PathResolver, cfg: &CoderConfig, scope_root: Option<&str>) -> InfoOutput {
let base_paths: Vec<String> = resolver
.roots()
.iter()
.map(|p| p.display().to_string())
.collect();
let primary_root = base_paths[0].clone();
// Echo the scope root only when it canonicalises inside an allowed root —
// the SAME acceptance the resolver applies, so `session_root` reflects
// exactly where scoped relative paths would anchor.
let session_root = scope_root
.and_then(|r| resolver.session_root(r))
.map(|p| p.display().to_string());

InfoOutput {
base_paths,
primary_root,
session_root,
non_accessible_globs: cfg.non_accessible_globs.clone(),
default_exclude_globs: cfg.default_exclude_globs.clone(),
max_read_bytes: cfg.max_read_bytes,
Expand Down Expand Up @@ -181,7 +208,7 @@ mod tests {
let non_canon = tmp.path().join(".");
let (resolver, cfg) = make_resolver_cfg(vec![non_canon], vec![]);

let out = inner(&resolver, &cfg);
let out = inner(&resolver, &cfg, None);

let expected_canon = std::fs::canonicalize(tmp.path())
.unwrap()
Expand Down Expand Up @@ -222,7 +249,7 @@ mod tests {
});
let resolver = Arc::new(PathResolver::new(&cfg).unwrap());

let out = inner(&resolver, &cfg);
let out = inner(&resolver, &cfg, None);

// primary_root == base_paths[0]
assert!(!out.base_paths.is_empty());
Expand Down Expand Up @@ -250,5 +277,33 @@ mod tests {

// version
assert_eq!(out.version, env!("CARGO_PKG_VERSION"));

// No scope stamped → no session_root.
assert_eq!(out.session_root, None);
}

/// A scoped call echoes the canonical session working directory in
/// `session_root`; an unscoped call reports `None`. A scope root that
/// canonicalises OUTSIDE every allowed root is not echoed (mirrors the
/// resolver's own rejection).
#[test]
fn session_root_reflects_scope() {
let tmp = tempdir().unwrap();
let sub = tmp.path().join("workdir");
std::fs::create_dir(&sub).unwrap();
let (resolver, cfg) = make_resolver_cfg(vec![tmp.path().to_path_buf()], vec![]);

// Unscoped: None.
assert_eq!(inner(&resolver, &cfg, None).session_root, None);

// Scoped inside an allowed root: canonical path echoed.
let canon_sub = std::fs::canonicalize(&sub).unwrap().display().to_string();
let scoped = inner(&resolver, &cfg, Some(sub.to_str().unwrap()));
assert_eq!(scoped.session_root, Some(canon_sub));

// Scope outside every allowed root: not echoed.
let outside = tempdir().unwrap();
let out = inner(&resolver, &cfg, Some(outside.path().to_str().unwrap()));
assert_eq!(out.session_root, None);
}
}
8 changes: 5 additions & 3 deletions shell/src/code/functions/list_folder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ use crate::code::path::PathResolver;
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(example = "example_list_folder_input")]
pub struct ListFolderInput {
/// Folder to list. Relative to the primary allowed root, or an absolute
/// path inside any allowed root. Defaults to `.` (the primary root
/// itself). Call `coder::info` to see the allowed roots. Paths outside
/// Folder to list. Relative paths resolve against the session working
/// directory when the session is scoped (the default for console chats),
/// otherwise against the primary allowed root; an absolute path must sit
/// inside an allowed root. Defaults to `.` (that anchor root itself). Call
/// `coder::info` for the allowed roots and the session root. Paths outside
/// every allowed root are rejected — use the shell worker's
/// `shell::fs::*` for host paths outside the jail.
#[serde(default = "default_path")]
Expand Down
Loading
Loading