Merge pull request #6713 from Hmbown/feat/6689-hook-execution-receipt

feat(hooks): export a post-admission execution receipt to tool_call_after (#6689)
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-29 07:19:03 -07:00
commit b66b4ab46c
12 files changed
+715 -9

No files matched your search

+2
View File
@@ -53,10 +53,12 @@ quieter, and Fleet runs can be checked before they spend anything.
- **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
- **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)).
- **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
- **[@wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)).
### Added
- `tool_call_after` hooks for shell tools receive `DEEPSEEK_TOOL_EXECUTION_RECEIPT`: the command that actually ran after admission, its working directory, how it ended, and bounded stdout/stderr previews, so a hook can record exactly what executed ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), requested by [@wuisabel-gif](https://github.com/wuisabel-gif)).
- Runtime API: turns now record what they produced. Each item and turn
carries typed artifact references (path, kind, size, revision, and a
restore point when file-revert would accept one) for files a tool wrote,
+2
View File
@@ -53,10 +53,12 @@ quieter, and Fleet runs can be checked before they spend anything.
- **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
- **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)).
- **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
- **[@wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)).
### Added
- `tool_call_after` hooks for shell tools receive `DEEPSEEK_TOOL_EXECUTION_RECEIPT`: the command that actually ran after admission, its working directory, how it ended, and bounded stdout/stderr previews, so a hook can record exactly what executed ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), requested by [@wuisabel-gif](https://github.com/wuisabel-gif)).
- Runtime API: turns now record what they produced. Each item and turn
carries typed artifact references (path, kind, size, revision, and a
restore point when file-revert would accept one) for files a tool wrote,
+3 -3
View File
@@ -31,9 +31,9 @@ pub use config::{
PROJECT_HOOKS_TEMPLATE, workspace_allows_project_hooks,
};
pub(crate) use executor::{
HOOK_CONTEXT_AGGREGATE_MAX_CHARS, HOOK_LABEL_MAX_CHARS, generic_unavailable_detail,
parse_tool_call_before_stdout, sanitize_hook_denial_reason, sanitize_hook_label,
sanitize_hook_line, sanitize_hook_text,
HOOK_CONTEXT_AGGREGATE_MAX_CHARS, HOOK_EXECUTION_RECEIPT_MAX_BYTES, HOOK_LABEL_MAX_CHARS,
generic_unavailable_detail, parse_tool_call_before_stdout, sanitize_hook_denial_reason,
sanitize_hook_label, sanitize_hook_line, sanitize_hook_text,
};
#[cfg(test)]
pub(crate) use executor::{
+176
View File
@@ -47,6 +47,10 @@ pub struct HookContext {
/// command usually has no exit code, so this is how a hook tells it apart
/// from a tool that reported nothing.
pub tool_status: Option<String>,
/// Serialized post-admission shell execution receipt (#6689), exported as
/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. Complete JSON or absent — never a
/// truncated document — and at most [`HOOK_EXECUTION_RECEIPT_MAX_BYTES`].
pub tool_execution_receipt: Option<String>,
/// Whether tool succeeded
pub tool_success: Option<bool>,
/// Current mode
@@ -115,6 +119,7 @@ impl HookContext {
};
let mut context = self.with_tool_result(&text, success, reported_tool_exit_code(result));
context.tool_status = reported_tool_status(result).map(str::to_string);
context.tool_execution_receipt = reported_tool_execution_receipt(result);
context
}
@@ -179,6 +184,15 @@ impl HookContext {
bound(&mut self.message, HOOK_MESSAGE_CONTEXT_MAX_BYTES);
bound(&mut self.error_message, HOOK_ERROR_CONTEXT_MAX_BYTES);
bound(&mut self.model, HOOK_OBSERVER_METADATA_MAX_BYTES);
// A receipt is complete JSON or nothing: truncating it would export a
// broken document, so an oversized one is dropped instead.
if self
.tool_execution_receipt
.as_ref()
.is_some_and(|receipt| receipt.len() > HOOK_EXECUTION_RECEIPT_MAX_BYTES)
{
self.tool_execution_receipt = None;
}
if let Some(workspace) = self.workspace.take() {
self.workspace = Some(PathBuf::from(truncate_env_value(
&workspace.to_string_lossy(),
@@ -225,6 +239,14 @@ impl HookContext {
if let Some(ref status) = self.tool_status {
env.insert("DEEPSEEK_TOOL_STATUS".to_string(), status.clone());
}
if let Some(ref receipt) = self.tool_execution_receipt
&& receipt.len() <= HOOK_EXECUTION_RECEIPT_MAX_BYTES
{
env.insert(
"DEEPSEEK_TOOL_EXECUTION_RECEIPT".to_string(),
receipt.clone(),
);
}
if let Some(ref mode) = self.mode {
env.insert("DEEPSEEK_MODE".to_string(), mode.clone());
}
@@ -405,6 +427,12 @@ const HOOK_TOOL_ARGS_ENV_MAX_BYTES: usize = 10_000;
/// Largest raw tool result retained in an observer job before enqueue.
const HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES: usize = 10_000;
/// Largest serialized shell execution receipt exported through
/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. The shell tool fits its output previews
/// beneath this bound; the hook boundary drops anything larger rather than
/// truncate a JSON document.
pub(crate) const HOOK_EXECUTION_RECEIPT_MAX_BYTES: usize = 32 * 1024;
/// Largest error retained in an observer job before enqueue.
const HOOK_ERROR_CONTEXT_MAX_BYTES: usize = 5_000;
@@ -1435,6 +1463,9 @@ impl HookExecutor {
// raw_arg: cmd.exe does not parse the CRT-style \" escapes that
// Command::arg would insert, so pass the command line verbatim.
cmd.arg("/C").raw_arg(command);
// Only this call's context may supply a receipt. In particular,
// a Codewhale launched from another hook must not inherit one.
cmd.env_remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT");
cmd
}
#[cfg(not(windows))]
@@ -1446,6 +1477,9 @@ impl HookExecutor {
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
// Only this call's context may supply a receipt. In particular,
// a Codewhale launched from another hook must not inherit one.
cmd.env_remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT");
cmd
}
}
@@ -3011,6 +3045,24 @@ fn reported_tool_status(
}
}
/// Read the post-admission execution receipt a shell tool recorded (#6689),
/// serialized for `DEEPSEEK_TOOL_EXECUTION_RECEIPT`.
///
/// Only a schema-1 object within the size bound counts. The receipt is built
/// by the shell tool from what its process manager recorded at spawn; it is
/// never reconstructed here from the before-hook input, which can differ from
/// what actually ran.
fn reported_tool_execution_receipt(
result: &Result<crate::tools::spec::ToolResult, crate::tools::spec::ToolError>,
) -> Option<String> {
let receipt = reported_tool_metadata(result)?.get("execution_receipt")?;
if receipt.get("schema_version")?.as_u64()? != 1 {
return None;
}
let encoded = serde_json::to_string(receipt).ok()?;
(encoded.len() <= HOOK_EXECUTION_RECEIPT_MAX_BYTES).then_some(encoded)
}
/// Read the process exit code a tool reported, when it reported one.
///
/// The one source for `DEEPSEEK_TOOL_EXIT_CODE`: the TUI and the Runtime API
@@ -5617,6 +5669,130 @@ command = "echo project"
);
}
/// #6689: `DEEPSEEK_TOOL_EXECUTION_RECEIPT` is read from the metadata a
/// shell tool recorded — on a failed call as well as a successful one —
/// and is complete JSON or absent. The existing variables do not change.
#[test]
fn execution_receipt_env_is_complete_json_or_absent() {
use crate::tools::spec::{ToolError, ToolResult};
let receipt = json!({"schema_version": 1, "command": "printf effective",
"cwd": "/tmp", "state": "completed", "scope": "local", "exit_code": 7,
"stdout": "\u{1f40b}", "stderr": "", "stdout_truncated": false,
"stderr_truncated": false, "output_kind": "separate"});
let plain = HookContext::new()
.with_tool_name("Bash")
.with_tool_outcome(&Ok(ToolResult::success("out")));
let legacy = plain.to_env_vars();
assert!(!legacy.contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT"));
let with_receipt = HookContext::new()
.with_tool_name("Bash")
.with_tool_outcome(&Ok(
ToolResult::success("out").with_metadata(json!({"execution_receipt": receipt}))
));
let mut env = with_receipt.to_env_vars();
let encoded = env
.remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT")
.expect("receipt exported");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&encoded).unwrap(),
receipt
);
assert_eq!(env, legacy, "existing variables are unchanged");
let failed =
HookContext::new().with_tool_outcome(&Err(ToolError::execution_failed_with_metadata(
"boom",
json!({"exit_code": 7, "execution_receipt": receipt}),
)));
assert!(
failed
.to_env_vars()
.contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT")
);
// An unknown schema or an oversized document is dropped, not cut.
for bad in [
json!({"schema_version": 2, "command": "x"}),
json!({"command": "x"}),
json!({"schema_version": 1,
"stdout": "x".repeat(super::HOOK_EXECUTION_RECEIPT_MAX_BYTES)}),
] {
let context = HookContext::new().with_tool_outcome(&Ok(
ToolResult::success("out").with_metadata(json!({"execution_receipt": bad}))
));
assert!(context.tool_execution_receipt.is_none());
}
let oversized = HookContext {
tool_execution_receipt: Some("x".repeat(super::HOOK_EXECUTION_RECEIPT_MAX_BYTES + 1)),
..HookContext::new()
};
assert!(
!oversized
.to_env_vars()
.contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT")
);
assert!(
oversized
.bounded_for_observer()
.tool_execution_receipt
.is_none()
);
}
/// An absent receipt must be absent in the actual child environment,
/// even when a nested Codewhale inherited an outer hook's receipt.
#[cfg(unix)]
#[test]
fn execution_receipt_never_inherits_another_calls_environment() {
let _env = lock_test_env();
let _stale = EnvVarGuard::set("DEEPSEEK_TOOL_EXECUTION_RECEIPT", "stale-outer-receipt");
let current = r#"{"schema_version":1,"command":"current call"}"#;
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("receipt-env.txt");
let command = write_hook_script(
&dir,
"capture_receipt_env.sh",
&format!(
"#!/bin/sh\nprintf '%s' \"${{DEEPSEEK_TOOL_EXECUTION_RECEIPT-unset}}\" > {}\n",
out.display()
),
);
for background in [false, true] {
let mut hook = Hook::new(HookEvent::ToolCallAfter, &command);
hook.background = background;
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![hook],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
for (receipt, expected) in [
(None, "unset"),
(
Some("x".repeat(HOOK_EXECUTION_RECEIPT_MAX_BYTES + 1)),
"unset",
),
(Some(current.to_string()), current),
] {
if out.exists() {
std::fs::remove_file(&out).unwrap();
}
let context = HookContext {
tool_execution_receipt: receipt,
..HookContext::new()
};
let results = executor.execute(HookEvent::ToolCallAfter, &context);
assert_eq!(results.len(), 1);
assert!(results[0].success);
assert_eq!(wait_for_captured_output(&out), expected);
}
}
}
#[test]
fn observer_context_is_bounded_before_enqueue() {
let huge = "用户".repeat(20_000);
+6
View File
@@ -14698,6 +14698,12 @@ impl RuntimeThreadManager {
}
obj.insert("tool_result_for".to_string(), json!(id));
obj.insert("is_error".to_string(), json!(!output.success));
// The shell execution receipt (#6689) is
// for completion hooks, which already
// read it from the live result; it
// repeats output previews `detail`
// holds, so it is never persisted.
obj.remove("execution_receipt");
}
// Failed calls count too: a large error
// output spills like any other.
+202 -5
View File
@@ -1127,6 +1127,9 @@ pub struct BackgroundShell {
lifecycle_seq: u64,
last_lifecycle_status: Option<ShellStatus>,
last_lifecycle_bytes: usize,
/// The terminal status came from a failed `try_wait`, not an observed
/// exit or signal. Such a run gets no execution receipt (#6689).
wait_failed: bool,
}
#[derive(Clone)]
@@ -1297,6 +1300,7 @@ impl BackgroundShell {
Ok(None) => false, // Still running
Err(_) => {
self.status = ShellStatus::Failed;
self.wait_failed = true;
self.heavy_permit.take();
self.collect_output();
true
@@ -2027,6 +2031,7 @@ impl ShellManager {
lifecycle_seq: 0,
last_lifecycle_status: None,
last_lifecycle_bytes: 0,
wait_failed: false,
},
);
}
@@ -2883,6 +2888,7 @@ impl ShellManager {
lifecycle_seq: 0,
last_lifecycle_status: None,
last_lifecycle_bytes: 0,
wait_failed: false,
};
#[cfg(unix)]
@@ -4767,10 +4773,29 @@ async fn execute_foreground_via_background(
extra_env: HashMap<String, String>,
direct_argv: bool,
timeout_bounds_ms: (u64, u64),
wants_receipt: bool,
receipt_identity: &mut Option<ShellExecutionIdentity>,
) -> Result<ShellResult> {
let timeout_ms =
timeout_ms.map(|timeout| timeout.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1));
let spawn_timeout_ms = timeout_ms.unwrap_or(timeout_bounds_ms.1);
// Freeze the receipt's directory before execution and hand that same
// resolved spelling to the OS. Looking up the original symlink after
// the command runs can name a different directory than the one it used.
// Resolution is asynchronous; failure leaves the ordinary execution
// path intact but cannot produce an exact receipt.
let receipt_cwd = if wants_receipt {
match working_dir.as_deref() {
Some(cwd) => tokio::fs::canonicalize(cwd)
.await
.ok()
.and_then(|path| path.into_os_string().into_string().ok()),
None => None,
}
} else {
None
};
let working_dir = receipt_cwd.clone().or(working_dir);
let task_id = {
let mut manager = context
.shell_manager
@@ -4802,11 +4827,34 @@ async fn execute_foreground_via_background(
.ok_or_else(|| anyhow!("foreground shell did not return a process id"))?;
// Classify before releasing the manager lock: even an immediately
// completed foreground command must never look like background work.
manager
let process = manager
.processes
.get_mut(&task_id)
.ok_or_else(|| anyhow!("foreground shell {task_id} is not tracked"))?
.background = false;
.ok_or_else(|| anyhow!("foreground shell {task_id} is not tracked"))?;
process.background = false;
// #6689: the receipt identity is what the manager recorded for this
// spawn — the admitted command and the directory handed to the OS —
// never the before-hook request. Only pipe-backed, unsandboxed local
// runs through a POSIX-style `<shell> <flag> <source>` dispatcher
// qualify: a PTY, the hardened read-only argv rewrite, an OS sandbox
// wrapper, Windows shell prefixes, and a PowerShell dispatcher (even
// `$SHELL=pwsh` on Unix, which wraps the source or runs it from a temp
// `-File`) all change what the process executes relative to this string.
if receipt_cwd.is_some()
&& !cfg!(windows)
&& !tty
&& !direct_argv
&& !spawned.sandboxed
&& !spawned.sandbox_denied
&& !crate::shell_dispatcher::global_dispatcher()
.kind()
.is_powershell()
{
*receipt_identity = Some(ShellExecutionIdentity {
command: process.command.clone(),
cwd: process.working_dir.clone(),
});
}
task_id
};
let mut foreground = ForegroundShellGuard {
@@ -4871,6 +4919,16 @@ async fn execute_foreground_via_background(
if manager.poll_status(&task_id)? == ShellStatus::Running {
None
} else {
// #6689: a status from a failed wait is neither an observed
// exit nor an interruption, so the receipt is left out rather
// than reporting a guessed state.
if manager
.processes
.get(&task_id)
.is_none_or(|shell| shell.wait_failed)
{
*receipt_identity = None;
}
let snapshot = manager.get_output(&task_id, false, 0)?;
// Ordering matters: the snapshot is taken before the
// acknowledgement releases the retained bytes.
@@ -4937,6 +4995,110 @@ impl Drop for ForegroundShellGuard {
}
}
/// The admitted command and working directory a foreground spawn recorded,
/// for the `tool_call_after` execution receipt (#6689).
struct ShellExecutionIdentity {
command: String,
cwd: PathBuf,
}
/// Largest command or working directory a receipt carries. Identities are
/// exact or absent, never truncated.
const EXECUTION_RECEIPT_IDENTITY_MAX_BYTES: usize = 8 * 1024;
/// Starting per-stream output preview in an execution receipt. Halved until
/// the serialized receipt fits `HOOK_EXECUTION_RECEIPT_MAX_BYTES`.
const EXECUTION_RECEIPT_PREVIEW_MAX_BYTES: usize = 8 * 1024;
/// Keep both ends of an output stream — the first lines and the final
/// diagnostic — cut on UTF-8 boundaries with a visible marker.
fn execution_receipt_preview(text: &str, budget: usize) -> (String, bool) {
if text.len() <= budget {
return (text.to_owned(), false);
}
let mut head = budget / 2;
while !text.is_char_boundary(head) {
head -= 1;
}
let mut tail = text.len() - budget / 2;
while !text.is_char_boundary(tail) {
tail += 1;
}
(
format!(
"{}\n[receipt preview truncated]\n{}",
&text[..head],
&text[tail..]
),
true,
)
}
/// Build the schema-1 execution receipt for a settled foreground shell run
/// (#6689), exported to `tool_call_after` hooks as
/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`.
///
/// Returns `None` — absence, which implies neither success nor failure — for
/// a run still in flight, a sandboxed run, or an identity that is not exact:
/// empty, over the identity bound, containing NUL, or a relative or non-UTF-8
/// directory. `output_kind` is `"combined"` when stdout and stderr shared one
/// pipe, so `stdout` holds the combined preview.
fn shell_execution_receipt(
identity: &ShellExecutionIdentity,
result: &ShellResult,
output_kind: &'static str,
) -> Option<serde_json::Value> {
let command = identity.command.as_str();
let cwd = identity.cwd.to_str()?;
if command.is_empty()
|| command.len() > EXECUTION_RECEIPT_IDENTITY_MAX_BYTES
|| command.contains('\0')
|| cwd.len() > EXECUTION_RECEIPT_IDENTITY_MAX_BYTES
|| cwd.contains('\0')
|| !identity.cwd.is_absolute()
|| result.sandboxed
|| result.sandbox_denied
{
return None;
}
// A nonzero exit is still a completed run; `interrupted` is a signal,
// kill, cancel, or timeout. The exit code is only ever the observed one.
let state = match result.status {
ShellStatus::Completed => "completed",
ShellStatus::Failed if result.exit_code.is_some() => "completed",
ShellStatus::Failed | ShellStatus::Killed | ShellStatus::TimedOut => "interrupted",
ShellStatus::Running => return None,
};
let mut budget = EXECUTION_RECEIPT_PREVIEW_MAX_BYTES;
loop {
let (stdout, stdout_clipped) = execution_receipt_preview(&result.stdout, budget);
let (stderr, stderr_clipped) = execution_receipt_preview(&result.stderr, budget);
let receipt = json!({
"schema_version": 1,
"command": command,
"cwd": cwd,
"state": state,
"scope": "local",
"exit_code": result.exit_code,
"stdout": stdout,
"stderr": stderr,
"stdout_truncated": result.stdout_truncated || stdout_clipped,
"stderr_truncated": result.stderr_truncated || stderr_clipped,
"output_kind": output_kind,
});
// The bound is on the serialized form, so JSON escaping counts.
if serde_json::to_vec(&receipt).ok()?.len()
<= crate::hooks::HOOK_EXECUTION_RECEIPT_MAX_BYTES
{
return Some(receipt);
}
if budget == 0 {
return None;
}
budget /= 2;
}
}
const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64;
/// Initial cadence for foreground-via-background completion polling. Fast
@@ -5005,6 +5167,7 @@ fn finish_contract_bash_result(
result: ShellResult,
timeout_ms: Option<u64>,
context: &ToolContext,
execution_receipt: Option<serde_json::Value>,
) -> Result<ToolResult, ToolError> {
let sandbox_denied_hint = shell_sandbox_denied_hint(context, &result);
let mut output = result.stdout.clone();
@@ -5016,12 +5179,15 @@ fn finish_contract_bash_result(
format!("{hint}\n\n{output}")
};
}
let metadata = json!({
let mut metadata = json!({
"evidence_routing": "inline", "exit_code": result.exit_code,
"status": format!("{:?}", result.status), "duration_ms": result.duration_ms,
"sandboxed": result.sandboxed, "sandbox_type": result.sandbox_type,
"task_id": result.task_id, "backgrounded": result.status == ShellStatus::Running,
});
if let Some(receipt) = execution_receipt {
metadata["execution_receipt"] = receipt;
}
if result.status == ShellStatus::Running {
let task_id = result.task_id.as_deref().unwrap_or("unknown");
let partial = (!output.is_empty()).then(|| format!("\n\nOutput so far:\n{output}"));
@@ -5932,6 +6098,14 @@ impl ToolSpec for BashTool {
}
let mut lifecycle_warning = None;
let mut receipt_identity = None;
// #6689: the receipt exists for completion hooks to read. Without one
// registered it would only ride along in tool metadata, which the
// Runtime API persists and emits for every call.
let wants_receipt = context.runtime.hook_executor.as_ref().is_some_and(|hooks| {
hooks.has_hooks_for_event(crate::hooks::HookEvent::ToolCallAfter)
|| hooks.has_hooks_for_event(crate::hooks::HookEvent::OnError)
});
let result = if interactive {
let mut manager = context
.shell_manager
@@ -6015,6 +6189,8 @@ impl ToolSpec for BashTool {
} else {
(1_000, 600_000)
},
wants_receipt,
&mut receipt_identity,
)
.await
};
@@ -6037,8 +6213,26 @@ impl ToolSpec for BashTool {
.cancel_token
.as_ref()
.is_some_and(|token| token.is_cancelled());
// Lowercase `bash` joins stdout and stderr on one pipe, so
// its preview is combined output, not stdout.
let execution_receipt = receipt_identity.as_ref().and_then(|identity| {
shell_execution_receipt(
identity,
&result,
if self.optional_timeout {
"combined"
} else {
"separate"
},
)
});
if self.optional_timeout {
return finish_contract_bash_result(result, timeout_ms, context);
return finish_contract_bash_result(
result,
timeout_ms,
context,
execution_receipt,
);
}
let task_id_str = result.task_id.clone().unwrap_or_default();
let stdout_summary = summarize_output(&result.stdout);
@@ -6169,6 +6363,9 @@ impl ToolSpec for BashTool {
}),
}),
});
if let Some(receipt) = execution_receipt {
metadata["execution_receipt"] = receipt;
}
metadata["backgrounded"] = json!(background || backgrounded_foreground);
if persist {
metadata["persist_requested"] = json!(true);
+247 -1
View File
@@ -263,6 +263,7 @@ fn contract_bash_nonzero_is_an_error_with_status_after_output() {
},
None,
&ToolContext::new("."),
None,
)
.expect_err("nonzero must be a failed tool call");
assert!(
@@ -292,6 +293,250 @@ async fn lowercase_bash_returns_one_ordered_stream() {
assert_eq!(result.content, "out-1err-2out-3");
}
/// #6689: the receipt is exact-or-absent, fits the hook bound after JSON
/// escaping, and reports how the run ended without inventing an exit code.
#[test]
fn execution_receipt_is_bounded_and_reports_truthful_state() {
let tmp = tempdir().unwrap();
let identity = |command: &str, cwd: &Path| ShellExecutionIdentity {
command: command.to_string(),
cwd: cwd.to_path_buf(),
};
let cwd = tmp.path().canonicalize().unwrap();
let mut result = failed_network_shell_result(
&"\u{1f40b}\n\"\u{1}".repeat(12_000),
&"err\n".repeat(12_000),
);
result.sandboxed = false;
result.sandbox_type = None;
result.stdout_truncated = true;
result.exit_code = None;
result.status = ShellStatus::Killed;
let receipt = shell_execution_receipt(&identity("printf hi", &cwd), &result, "separate")
.expect("receipt");
let encoded = serde_json::to_string(&receipt).unwrap();
assert!(encoded.len() <= crate::hooks::HOOK_EXECUTION_RECEIPT_MAX_BYTES);
assert_eq!(serde_json::from_str::<Value>(&encoded).unwrap(), receipt);
assert_eq!(receipt["schema_version"], 1);
assert_eq!(receipt["command"], "printf hi");
assert_eq!(receipt["cwd"], cwd.to_str().unwrap());
assert_eq!(receipt["state"], "interrupted");
assert!(receipt["exit_code"].is_null());
assert_eq!(receipt["stdout_truncated"], true);
assert_eq!(receipt["stderr_truncated"], true);
assert!(
receipt["stderr"]
.as_str()
.unwrap()
.contains("[receipt preview truncated]")
);
// A nonzero exit is a completed run, and a 64-bit code survives.
result.status = ShellStatus::Failed;
result.exit_code = Some(3_221_225_477);
let receipt = shell_execution_receipt(&identity("x", &cwd), &result, "combined").unwrap();
assert_eq!(receipt["state"], "completed");
assert_eq!(receipt["exit_code"], 3_221_225_477_i64);
assert_eq!(receipt["output_kind"], "combined");
result.status = ShellStatus::TimedOut;
result.exit_code = None;
let receipt = shell_execution_receipt(&identity("x", &cwd), &result, "separate").unwrap();
assert_eq!(receipt["state"], "interrupted");
// Absent, never truncated or guessed.
result.status = ShellStatus::Running;
assert!(shell_execution_receipt(&identity("x", &cwd), &result, "separate").is_none());
result.status = ShellStatus::Completed;
for command in ["", "bad\0command"] {
assert!(shell_execution_receipt(&identity(command, &cwd), &result, "separate").is_none());
}
let long = "x".repeat(EXECUTION_RECEIPT_IDENTITY_MAX_BYTES + 1);
assert!(shell_execution_receipt(&identity(&long, &cwd), &result, "separate").is_none());
let long_cwd = PathBuf::from(format!("/{long}"));
assert!(shell_execution_receipt(&identity("x", &long_cwd), &result, "separate").is_none());
assert!(
shell_execution_receipt(&identity("x", Path::new("relative")), &result, "separate")
.is_none()
);
result.sandboxed = true;
assert!(shell_execution_receipt(&identity("x", &cwd), &result, "separate").is_none());
}
/// #6689: a settled foreground run records the command and directory the
/// process manager spawned, on success and on failure, and background runs
/// carry no receipt. The workspace is opened through a symlink so the default
/// directory and an explicit `cwd` would otherwise be spelled differently.
#[cfg(unix)]
#[tokio::test]
async fn foreground_shell_results_carry_the_spawned_execution_receipt() {
let tmp = tempdir().unwrap();
let real = tmp.path().join("real");
std::fs::create_dir_all(real.join("child")).unwrap();
let workspace = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &workspace).unwrap();
let mut context = ToolContext::new(workspace.clone())
.with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess);
context.auto_approve = true;
let tool = BashTool::new("Bash");
// No completion hook registered: nothing reads a receipt, so none rides
// along in the metadata the Runtime API persists.
let unobserved = tool
.execute(json!({"command": "pwd"}), &context)
.await
.unwrap();
assert!(
unobserved
.metadata
.as_ref()
.unwrap()
.get("execution_receipt")
.is_none()
);
let hooks = crate::hooks::HookExecutor::new(
crate::hooks::HooksConfig {
enabled: true,
hooks: vec![crate::hooks::Hook::new(
crate::hooks::HookEvent::ToolCallAfter,
"true",
)],
..crate::hooks::HooksConfig::default()
},
workspace.clone(),
);
context.runtime.hook_executor = Some(std::sync::Arc::new(hooks));
// Exact strings, not re-canonicalized: both spellings must already agree.
let real = real.canonicalize().unwrap();
let real_str = real.to_str().unwrap();
let child_str = real.join("child");
let child_str = child_str.to_str().unwrap();
let default_dir = tool
.execute(json!({"command": "pwd"}), &context)
.await
.unwrap();
let explicit_dir = tool
.execute(json!({"command": "pwd", "cwd": "."}), &context)
.await
.unwrap();
for result in [&default_dir, &explicit_dir] {
assert_eq!(
result.metadata.as_ref().unwrap()["execution_receipt"]["cwd"],
real_str
);
}
let command = "printf effective; printf diagnostic >&2; exit 7";
let result = tool
.execute(json!({"command": command, "cwd": "child"}), &context)
.await
.unwrap();
let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"];
assert_eq!(receipt["command"], command);
assert_eq!(receipt["cwd"], child_str);
assert_eq!(receipt["stdout"], "effective");
assert_eq!(receipt["stderr"], "diagnostic");
assert_eq!(receipt["exit_code"], 7);
assert_eq!(receipt["state"], "completed");
assert_eq!(receipt["output_kind"], "separate");
let interrupted = tool
.execute(json!({"command": "kill -TERM $$"}), &context)
.await
.unwrap();
let receipt = &interrupted.metadata.as_ref().unwrap()["execution_receipt"];
assert_eq!(receipt["state"], "interrupted");
assert!(receipt["exit_code"].is_null());
let background = tool
.execute(
json!({"command": "printf background", "background": true}),
&context,
)
.await
.unwrap();
assert!(
background
.metadata
.as_ref()
.unwrap()
.get("execution_receipt")
.is_none()
);
// Lowercase `bash` shares one pipe: the preview is combined output, and a
// failing command's error still carries the receipt for hooks.
let result = LowercaseBashTool
.execute(
json!({"command": "printf merged; printf diagnostic >&2"}),
&context,
)
.await
.unwrap();
let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"];
assert_eq!(receipt["output_kind"], "combined");
assert_eq!(receipt["stdout"], "mergeddiagnostic");
assert_eq!(receipt["stderr"], "");
assert_eq!(receipt["state"], "completed");
let error = LowercaseBashTool
.execute(json!({"command": "printf partial; exit 3"}), &context)
.await
.expect_err("nonzero exit is a failed bash call");
let receipt = &error.metadata().expect("failure metadata")["execution_receipt"];
assert_eq!(receipt["command"], "printf partial; exit 3");
assert_eq!(receipt["exit_code"], 3);
assert_eq!(receipt["state"], "completed");
assert_eq!(receipt["cwd"], real_str);
}
/// A post-run lookup of a retargeted workspace symlink would falsely name
/// the replacement directory. The receipt must keep the actual spawn path.
#[cfg(unix)]
#[tokio::test]
async fn execution_receipt_keeps_spawn_cwd_when_the_command_retargets_the_workspace() {
let tmp = tempdir().unwrap();
let real = tmp.path().join("real");
let other = tmp.path().join("other");
std::fs::create_dir(&real).unwrap();
std::fs::create_dir(&other).unwrap();
let workspace = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &workspace).unwrap();
let mut context = ToolContext::new(workspace.clone())
.with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess);
context.auto_approve = true;
context.runtime.hook_executor = Some(std::sync::Arc::new(crate::hooks::HookExecutor::new(
crate::hooks::HooksConfig {
enabled: true,
hooks: vec![crate::hooks::Hook::new(
crate::hooks::HookEvent::ToolCallAfter,
"true",
)],
..crate::hooks::HooksConfig::default()
},
workspace.clone(),
)));
let command = "pwd -P; rm ../link; ln -s other ../link; pwd -P";
let result = BashTool::new("Bash")
.execute(json!({"command": command}), &context)
.await
.unwrap();
assert!(result.success);
let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"];
let actual = real.canonicalize().unwrap();
let actual = actual.to_str().unwrap();
assert_eq!(receipt["command"], command);
assert_eq!(receipt["cwd"], actual);
assert_eq!(receipt["stdout"], format!("{actual}\n{actual}\n"));
assert_eq!(receipt["exit_code"], 0);
assert_eq!(
workspace.canonicalize().unwrap(),
other.canonicalize().unwrap()
);
}
#[cfg(unix)]
#[tokio::test]
async fn lowercase_bash_keeps_raw_command_under_readonly_policy() {
@@ -2538,7 +2783,7 @@ fn contract_bash_denial_surfaces_the_escalation_shape() {
let mut result = failed_network_shell_result("", "Operation not permitted");
result.sandbox_denied = true;
let error = finish_contract_bash_result(result, None, &ctx)
let error = finish_contract_bash_result(result, None, &ctx, None)
.expect_err("sandbox denial is a failed call");
assert!(
@@ -3774,6 +4019,7 @@ fn killed_shell_does_not_wait_for_blocked_reader_threads() {
lifecycle_seq: 0,
last_lifecycle_status: None,
last_lifecycle_bytes: 0,
wait_failed: false,
};
let started = std::time::Instant::now();
+1
View File
@@ -37,6 +37,7 @@ notes, and relevant issue/PR comments.
- **[aboimpinto](https://github.com/aboimpinto)** — restored a green Linux full-workspace test gate without loosening any test, twice ([#6581](https://github.com/Hmbown/Codewhale/pull/6581), [#6666](https://github.com/Hmbown/Codewhale/pull/6666)).
- **[dajiaohuang](https://github.com/dajiaohuang)** — validated `config set` values against the settings schema ([#6568](https://github.com/Hmbown/Codewhale/pull/6568)).
- **[Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance and its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
- **[wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)).
- **[Sh1Zuku / SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)).
**Reports and reproductions**
+46
View File
@@ -311,8 +311,54 @@ rebrand.
| `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`, `on_error` (tool failures) | `true` / `false` |
| `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` and `on_error` **when the tool reported one** | absent otherwise — never synthesized; set for a failing command as well as a passing one; 64-bit, so Windows crash codes such as `3221225477` survive |
| `DEEPSEEK_TOOL_STATUS` | `tool_call_after` and `on_error` **when a shell tool reported one** | `completed`, `failed`, `timed_out`, `killed`, or `running` (moved to the background); absent for other tools |
| `DEEPSEEK_TOOL_EXECUTION_RECEIPT` | `tool_call_after` and `on_error` **for a settled, local, foreground shell run** | complete JSON, at most 32 KiB, or absent; see [Execution receipt](#execution-receipt) |
| `DEEPSEEK_SESSION_COST` | when cost is supplied | USD, six decimal places |
### Execution receipt
`DEEPSEEK_TOOL_EXECUTION_RECEIPT` says what a shell tool (`bash`, `Bash`,
`exec_shell`) actually ran. The before-hook input is not the same thing: a
`tool_call_before` hook can rewrite it. The receipt is built from what the
process manager recorded when it spawned the process, after admission and
any rewrite.
```json
{"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","state":"completed","scope":"local","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_kind":"separate"}
```
| Field | Meaning |
| --- | --- |
| `command` | the admitted shell source handed to the shell, not the shell executable or its argv wrapper |
| `cwd` | the canonical absolute path of the directory the process started in: symlinks are resolved, so a directory has one spelling whether or not the call passed `cwd`; it is resolved before spawn and that same path is handed to the OS |
| `state` | `completed` for an observed exit, including a nonzero one; `interrupted` for a signal, kill, cancel, or timeout |
| `scope` | always `local` in schema 1 |
| `exit_code` | the observed integer, or `null`; never synthesized from `state` |
| `stdout`, `stderr` | previews of the tool's retained output, which may already omit early process output; long previews keep their first and last bytes around a `[receipt preview truncated]` marker |
| `stdout_truncated`, `stderr_truncated` | `true` when the tool's own output capture or the preview dropped bytes |
| `output_kind` | `separate` for `Bash` / `exec_shell`; `combined` for lowercase `bash`, whose stdout and stderr share one pipe — `stdout` then holds the combined preview and `stderr` is empty |
The rules are conservative:
- **Exact or absent.** `command` and `cwd` are never truncated. If either is
over 8 KiB, contains NUL, or the directory is relative, not UTF-8, or cannot
resolve before spawn, the receipt is left out. So is a run whose end the shell
tool could not observe (the OS wait itself failed): its state is unknown,
and the receipt does not guess it. Previews shrink until the serialized JSON fits 32 KiB;
if it still cannot fit, the receipt is left out rather than cut.
- **Absence means nothing.** It implies neither success nor failure. An inherited
`DEEPSEEK_TOOL_EXECUTION_RECEIPT` is cleared before applying the current call's context.
- **Scope.** A receipt is built only while a `tool_call_after` or `on_error`
hook is configured, and only for a settled, pipe-backed, unsandboxed, local
foreground run. Background launches, a foreground run moved to `/jobs`,
PTY (`tty` / `combined_output`) and interactive sessions, OS-sandboxed and
external-backend execution, the read-only shell's hardened argv, Windows,
a PowerShell shell on any platform (it wraps the source or runs it from a
temporary script), and calls refused before execution have none.
- **Hooks only.** The receipt is not kept in the durable Runtime API item
record; that record already carries the tool output.
- It is set for a failed run as well as a passing one, so `on_error` for a
failed shell call carries it too. Every other variable is unchanged.
**Mode-spelling note.** UI-fired events (`session_start`, `session_end`,
`message_submit`, `tool_call_after`, `mode_change`, `on_error`, `turn_end`,
`subagent_*`, `session_busy`, `session_idle`, `session_error`, `waiting_for_user`)
+1
View File
@@ -232,6 +232,7 @@
"@aboimpinto",
"@dajiaohuang",
"@Water-Run",
"@wuisabel-gif",
"@BX166",
"@SparkofSpike",
"@cenab"
+28
View File
@@ -163,8 +163,36 @@ Observer 并**不**意味着无副作用。observer hook 是以你的凭据运
| `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`、`on_error`(工具失败) | `true` / `false` |
| `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` 和 `on_error` **当工具报告了退出码时** | 否则不存在——绝不合成;命令失败时同样设置;64 位,因此 `3221225477` 这样的 Windows 崩溃码能完好保留 |
| `DEEPSEEK_TOOL_STATUS` | `tool_call_after` 和 `on_error` **当 shell 工具报告了状态时** | `completed`、`failed`、`timed_out`、`killed` 或 `running`(已转入后台);其他工具不存在 |
| `DEEPSEEK_TOOL_EXECUTION_RECEIPT` | `tool_call_after` 和 `on_error` **仅限已结束的本地前台 shell 运行** | 完整 JSON,最多 32 KiB,否则不存在;见下方“执行回执” |
| `DEEPSEEK_SESSION_COST` | 提供成本时 | USD,六位小数 |
### 执行回执
`DEEPSEEK_TOOL_EXECUTION_RECEIPT` 说明 shell 工具(`bash`、`Bash`、`exec_shell`)实际运行了什么。before-hook 的输入不等于实际执行的内容:`tool_call_before` hook 可以改写它。回执取自进程管理器在准入与改写之后启动进程时记录的内容。
```json
{"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","state":"completed","scope":"local","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_kind":"separate"}
```
| 字段 | 含义 |
| --- | --- |
| `command` | 交给 shell 的已准入命令源码,而不是 shell 可执行文件或其 argv 包装 |
| `cwd` | 进程启动时所在目录的规范绝对路径:解析符号链接,因此无论调用是否传入 `cwd`,同一目录只有一种写法;在启动前解析,并将同一路径交给操作系统 |
| `state` | 观察到退出(包括非零退出)为 `completed`;信号、kill、取消或超时为 `interrupted` |
| `scope` | schema 1 中始终为 `local` |
| `exit_code` | 观察到的整数,或 `null`;绝不根据 `state` 合成 |
| `stdout`、`stderr` | 工具已保留输出的预览,其中可能已经缺少进程输出的开头;过长的预览保留自身的首尾字节,中间以 `[receipt preview truncated]` 标记 |
| `stdout_truncated`、`stderr_truncated` | 工具自身的输出捕获或预览丢弃了字节时为 `true` |
| `output_kind` | `Bash` / `exec_shell` 为 `separate`;小写 `bash` 的 stdout 与 stderr 共用一个管道,为 `combined`——此时 `stdout` 是合并后的预览,`stderr` 为空 |
规则是保守的:
- **要么精确,要么不存在。** `command` 和 `cwd` 绝不截断。任一超过 8 KiB、包含 NUL,或目录是相对路径、非 UTF-8 或在启动前无法解析时,不导出回执。shell 工具无法观察到运行如何结束(操作系统的 wait 调用本身失败)时同样不导出:其状态未知,回执不做猜测。预览会缩短直到序列化后的 JSON 不超过 32 KiB;仍然放不下时不导出回执,而不是截断。
- **不存在不代表任何结果。** 既不意味着成功,也不意味着失败。应用当前调用的上下文前,会清除继承的 `DEEPSEEK_TOOL_EXECUTION_RECEIPT`。
- **范围。** 只有配置了 `tool_call_after` 或 `on_error` hook 时才会生成回执,且仅限已结束、基于管道、未沙箱化的本地前台运行。后台启动、转入 `/jobs` 的前台运行、PTY(`tty` / `combined_output`)与交互会话、OS 沙箱与外部后端执行、只读 shell 的加固 argv、Windows、任何平台上的 PowerShell(它会包装源码或通过临时脚本运行),以及执行前就被拒绝的调用都没有回执。
- **仅供 hook 使用。** 回执不写入持久化的 Runtime API 条目记录;该记录已包含工具输出。
- 失败的运行与成功的运行同样设置,因此 shell 调用失败时的 `on_error` 也带有它。其他变量均不变。
**模式拼写说明。** UI 触发的事件(`session_start`、`session_end`、`message_submit`、`tool_call_after`、`mode_change`、`on_error`、`turn_end`、`subagent_*`)会将 `DEEPSEEK_MODE` 设为 UI 标签——`ACT`、`PLAN`、`OPERATE`。`tool_call_before` 在引擎内部触发,并使用引擎自己的模式拼写(`Agent`、`Plan`、`Operate`)。`mode` 条件不区分大小写比较,因此 `{ type = "mode", mode = "plan" }` 两者都能匹配,但精确字符串匹配 `$DEEPSEEK_MODE` 的 hook 应同时接受两种拼写。
**`shell_env` 是受限的那个。** 它只接收 `DEEPSEEK_TOOL_NAME` 和 `DEEPSEEK_TOOL_ARGS`——没有会话 id、工作区、模型或模式。因此,`shell_env` hook 上的 `{ type = "mode", … }` 条件会在加载时被拒绝;请改用 `tool_name` 或 `tool_category` 来限定作用域。
+1
View File
@@ -25,6 +25,7 @@ export const RELEASE_CONTRIBUTORS: string[] = [
"@aboimpinto",
"@dajiaohuang",
"@Water-Run",
"@wuisabel-gif",
"@SparkofSpike",
];