mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
12 files changed
+715
-9
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
"@aboimpinto",
|
||||
"@dajiaohuang",
|
||||
"@Water-Run",
|
||||
"@wuisabel-gif",
|
||||
"@BX166",
|
||||
"@SparkofSpike",
|
||||
"@cenab"
|
||||
|
||||
@@ -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` 来限定作用域。
|
||||
|
||||
@@ -25,6 +25,7 @@ export const RELEASE_CONTRIBUTORS: string[] = [
|
||||
"@aboimpinto",
|
||||
"@dajiaohuang",
|
||||
"@Water-Run",
|
||||
"@wuisabel-gif",
|
||||
"@SparkofSpike",
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user