run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s
Nightly Build / build (push) Successful in 6m31s
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user