run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s

This commit is contained in:
2026-07-21 10:47:12 +01:00
parent 1db34b22ec
commit c8e4cb4384
20 changed files with 395 additions and 217 deletions
@@ -1,9 +1,10 @@
//! Working-directory argument rewriting and the per-tool-call dispatch router.
//! Per-tool-call dispatch router.
//!
//! Extracted from `run_agent_turn`: `effective_args` applies the RunContext working
//! directory to a call's arguments, and `execute_tool_call` routes an approved call
//! to the right executor (special non-cancellable paths + the unified cancellable
//! `ToolExecution` path).
//! Extracted from `run_agent_turn`: `execute_tool_call` routes an approved call to
//! the right executor (special non-cancellable paths + the unified cancellable
//! `ToolExecution` path). The session working directory is always the user's home
//! (`~`); tool calls receive their arguments unchanged, and the agent references
//! project files via the absolute agent path `projects/{owner}/{slug}/…`.
use serde_json::Value;
use tokio::sync::mpsc;
@@ -39,28 +40,6 @@ pub(super) enum DispatchResult {
}
impl ChatSessionHandler {
/// Applies the RunContext working directory to a tool call's arguments:
/// resolves a relative `path` against the effective WD and injects `workdir`
/// for `execute_cmd`. The caller keeps the original `arguments` for the
/// `ToolStart` event / DB logging; this returns the copy used for execution.
pub(super) async fn effective_args(&self, tool_name: &str, args: &Value) -> Value {
let mut effective = args.clone();
let wd = self.run_context.read().await
.as_ref()
.map(|rc| rc.effective_working_dir());
if let Some(wd) = wd {
if let Some(path) = effective["path"].as_str()
&& !std::path::Path::new(path).is_absolute()
{
effective["path"] = Value::String(wd.join(path).to_string_lossy().into_owned());
}
if tool_name == tn::EXECUTE_CMD && effective.get("workdir").is_none() {
effective["workdir"] = Value::String(wd.to_string_lossy().into_owned());
}
}
effective
}
/// Routes one already-approved tool call to the right executor. Covers the
/// special, non-cancellable paths (sub-agent, scratchpad, todos, clarification,
/// the `task_completed` stub) and the unified cancellable `ToolExecution` path
@@ -31,9 +31,9 @@ enum CallFlow {
/// Outcome of gating + dispatching one call inside a concurrent sub-agent batch,
/// carried from the concurrent phase to the ordered recording phase.
enum GatedExec {
/// Gate passed; the sub-agent produced an outcome to record. `effective` is the
/// working-dir-resolved args used for recording (FileChanged / logging).
Done { effective: serde_json::Value, outcome: ExecutionOutcome },
/// Gate passed; the sub-agent produced an outcome to record. `arguments` is
/// the call's args (used for FileChanged / logging).
Done { arguments: serde_json::Value, outcome: ExecutionOutcome },
/// Approval gate rejected the call — already marked/emitted by the gate; skip it.
Rejected,
/// The turn must end now: the clarification WS channel closed (dispatch returned
@@ -234,12 +234,12 @@ impl ChatSessionHandler {
self.tools.target_path(&call.name, &call.arguments),
).await;
// Resolve relative paths / inject workdir from the RunContext.
// `call.arguments` (originals) were used for the ToolStart event and DB
// logging above; `effective_args` is used from here on.
let effective_args = self.effective_args(&call.name, &call.arguments).await;
// Tool calls receive their arguments unchanged — the session working
// directory is always the user's home (`~`), and the agent references
// project files via their absolute agent path. `call.arguments` is both
// logged and executed.
match self.run_approval_gate(tool_call_id, &call.name, &effective_args, &config.agent_id, em).await? {
match self.run_approval_gate(tool_call_id, &call.name, &call.arguments, &config.agent_id, em).await? {
GateOutcome::Proceed => {}
GateOutcome::Rejected => return Ok(CallFlow::Continue),
GateOutcome::ChannelClosed => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
@@ -263,14 +263,14 @@ impl ChatSessionHandler {
// clarification WS channel closed — end the turn and leave the tool
// `pending` for resume to re-ask.
let outcome = match self.execute_tool_call(
stack_id, config, tool_call_id, &call.name, &effective_args, token, tx,
stack_id, config, tool_call_id, &call.name, &call.arguments, token, tx,
).await {
DispatchResult::Outcome(o) => o,
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
};
match self.record_tool_outcome(
tool_call_id, &call.name, &effective_args, outcome, em, Some(all_tool_calls),
tool_call_id, &call.name, &call.arguments, outcome, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => Ok(CallFlow::Continue),
RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)),
@@ -340,14 +340,13 @@ impl ChatSessionHandler {
{
let mut stream = stream::iter(jobs)
.map(|(idx, tool_call_id, name, arguments)| async move {
let effective = self.effective_args(&name, &arguments).await;
let gated = match self.run_approval_gate(
tool_call_id, &name, &effective, &config.agent_id, em,
tool_call_id, &name, &arguments, &config.agent_id, em,
).await {
Ok(GateOutcome::Proceed) => match self.execute_tool_call(
stack_id, config, tool_call_id, &name, &effective, token, tx,
stack_id, config, tool_call_id, &name, &arguments, token, tx,
).await {
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { effective, outcome }),
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { arguments, outcome }),
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
},
Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected),
@@ -370,9 +369,9 @@ impl ChatSessionHandler {
// The gate already marked the row rejected and emitted the event.
GatedExec::Rejected => {}
GatedExec::AbortTurn => abort = true,
GatedExec::Done { effective, outcome } => {
GatedExec::Done { arguments, outcome } => {
match self.record_tool_outcome(
*tool_call_id, &call.name, &effective, outcome, em, Some(all_tool_calls),
*tool_call_id, &call.name, &arguments, outcome, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => abort = true,
@@ -47,9 +47,11 @@ pub struct MessageBuilder {
pub max_history_messages: usize,
pub max_tool_result_chars: Option<usize>,
pub compactor: Option<Arc<ContextCompactor>>,
/// Effective working directory for this session. When set (e.g. from a project
/// RunContext), it overrides the process cwd in the date/time/OS/WD tail block.
pub working_directory: Option<std::path::PathBuf>,
/// Project root (agent path `projects/{owner}/{slug}`) when this is a project
/// session — used to resolve `__PROJECT_ROOT__` placeholders in `inject_memory`
/// paths. `None` for non-project sessions, in which case an `inject_memory`
/// entry that references `__PROJECT_ROOT__` is skipped (with a warning).
pub project_root: Option<String>,
}
impl MessageBuilder {
@@ -117,9 +119,7 @@ impl MessageBuilder {
// ── Skills index ──────────────────────────────────────────────────────
// Injected for every agent unless it opts out (`inject_skills: false`).
// Reuses the memory-path resolution so the shown path is relative when the
// index is under the session WD, absolute otherwise (it lives under Skald's
// own cwd, so it shows as absolute inside project sessions). Skipped silently
// Reuses the memory-path resolver for display consistency. Skipped silently
// when no skills are installed.
if meta.inject_skills {
let (abs, display) = self.resolve_memory_path(SKILLS_INDEX_PATH);
@@ -398,14 +398,11 @@ impl MessageBuilder {
None => format!("Current date and time: {formatted}"),
};
let cwd = self.working_directory.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
.display()
.to_string();
let cwd = "~";
Some(format!(
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
Filesystem tools and execute_cmd use this working directory for relative paths — \
no need to `cd` into it first.",
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
os_description()
))
} else {
@@ -455,17 +452,18 @@ impl MessageBuilder {
/// Builds the MCP list section that replaces the `__MCP_LIST__` sentinel.
/// Resolves an `inject_memory` entry to `(absolute path to read, path to show)`.
///
/// `$WD` expands to the session's effective working directory (RunContext WD, or the
/// process cwd when unset). The shown path is **relative to that working directory
/// when the file lives under it, absolute otherwise** — so when the agent references
/// it back via `edit_file`/`write_file`, the loop's working-directory injection
/// (which rewrites relative paths against the WD) resolves to the very same file.
/// `__PROJECT_ROOT__` expands to the session's project root (the agent path
/// `projects/{owner}/{slug}`, set on the RunContext for project sessions) —
/// e.g. `"__PROJECT_ROOT__/SKALD.md"` loads a project-local diary. The shown
/// path is the agent path itself, which the loop's filesystem routing
/// resolves back to the same file when the agent references it via
/// `edit_file`/`write_file`.
/// Loads an `inject_memory` entry, returning `(content, display_path)`.
///
/// Virtual memory paths are read from SQLite: `user-memory/…` from the owner
/// `pool`, `shared-memory/…` from the `shared_pool` (`system.db`). Everything
/// else (`data/…`, `$WD/…`) is an ordinary disk read. A missing note / file
/// yields `None`, rendered as "(file not created yet)".
/// else (`data/…`, `__PROJECT_ROOT__/…`, an absolute path) is an ordinary disk
/// read. A missing note / file yields `None`, rendered as "(file not created yet)".
async fn load_inject_memory(&self, mem_path: &str) -> (Option<String>, String) {
use crate::tools::fs::{classify_memory, MemScope};
if let Some(m) = classify_memory(mem_path) {
@@ -482,15 +480,22 @@ impl MessageBuilder {
}
fn resolve_memory_path(&self, mem_path: &str) -> (std::path::PathBuf, String) {
let wd = self.working_directory.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let expanded = mem_path.replace("$WD", &wd.display().to_string());
let abs = crate::tools::fs::resolve(&expanded)
.unwrap_or_else(|_| std::path::PathBuf::from(&expanded));
let display = match abs.strip_prefix(&wd) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => abs.to_string_lossy().into_owned(),
let display = if mem_path.contains("__PROJECT_ROOT__") {
match &self.project_root {
Some(root) => mem_path.replace("__PROJECT_ROOT__", root),
None => {
tracing::warn!(
mem_path,
"inject_memory entry references __PROJECT_ROOT__ but this session has no project root; skipping"
);
return (std::path::PathBuf::from(mem_path), mem_path.to_string());
}
}
} else {
mem_path.to_string()
};
let abs = crate::tools::fs::resolve(&display)
.unwrap_or_else(|_| std::path::PathBuf::from(&display));
(abs, display)
}
@@ -24,9 +24,9 @@ impl ChatSessionHandler {
cache_hints: bool,
capabilities: &[String],
) -> anyhow::Result<Vec<Value>> {
let effective_wd = self.run_context.read().await
let project_root = self.run_context.read().await
.as_ref()
.map(|rc| rc.effective_working_dir());
.and_then(|rc| rc.project_root.clone());
let builder = MessageBuilder {
pool: Arc::clone(&self.db),
shared_pool: Arc::clone(&self.shared_pool),
@@ -37,7 +37,7 @@ impl ChatSessionHandler {
max_history_messages: self.max_history_messages,
max_tool_result_chars: self.max_tool_result_chars,
compactor: self.compactor.clone(),
working_directory: effective_wd,
project_root,
};
// `pool` is passed in from the caller (always `&self.db`) but we take
// ownership via Arc::clone above so the signature stays backward-compatible.
@@ -326,11 +326,9 @@ impl ChatSessionHandler {
// sub-agent tools (`execute_task` mode=sync, `execute_subtask`,
// `run_subtask`) through the recursive interception in `dispatch.rs`;
// `build_execution` alone does not know them and would fail with
// "Unknown tool: execute_task". Apply the RunContext working dir exactly
// like the live loop.
let effective_args = self.effective_args(&tc.name, &args).await;
// "Unknown tool: execute_task". Args are passed through unchanged.
let outcome = match self.execute_tool_call(
stack_id, config, tc.id, &tc.name, &effective_args, token, tx,
stack_id, config, tc.id, &tc.name, &args, token, tx,
).await {
super::dispatch::DispatchResult::Outcome(o) => o,
// Clarification WS channel closed mid-resume — leave the tool pending
@@ -339,7 +337,7 @@ impl ChatSessionHandler {
};
// resume passes `None`: it does not accumulate ToolCallEvents nor re-emit
// FileChanged (only a live turn does). A /stop mid-resume returns Abort.
match self.record_tool_outcome(tc.id, &tc.name, &effective_args, outcome, &em, None).await? {
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, &em, None).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => return Ok(true),
}