Skip to content

Commit a12c8d5

Browse files
committed
fix(agent,channels): forward receipts through delegate sub-loops + strict disabled coverage
Address #6214 review feedback against #6182's full acceptance criteria. - Add `TOOL_LOOP_RECEIPT_CONTEXT` task-local in `agent::tool_receipts`, matching the existing `TOOL_LOOP_COST_TRACKING_CONTEXT` pattern. The orchestrator scopes the per-turn `Arc<Mutex<Vec<String>>>` collector plus the process-lifetime `ReceiptGenerator` clone before entering the tool-call loop. - `DelegateTool::execute_sync` reads the scope and forwards generator + collector into the sub-agent's `run_tool_call_loop`, replacing the prior `None, None` placeholders at delegate.rs:1184. Multi-agent resilience: `execute_parallel` captures the parent scope and re-enters it inside each spawned sub-agent so parallel sub-tool receipts land in the same per-turn collector via `Arc` sharing. Background spawns stay unsigned by design (per-turn collector is already rendered before they finish; documented as a known limitation). - Strict #6182 disabled coverage: `process_channel_message_disabled_receipt_generator_emits_no_receipts_anywhere` asserts no `zc-receipt-` token in any sent message and no `[receipt:` trailer in conversation history when `receipt_generator: None`. Distinct from the existing `show_in_response = false` test (which keeps the generator on but suppresses the user-visible block). - Delegate forwarding coverage: positive test exercises `execute_agentic` inside a scoped `TOOL_LOOP_RECEIPT_CONTEXT` and verifies a real `echo_tool` sub-call lands in the parent collector with a valid `zc-receipt-` HMAC token; negative test confirms unsigned sub-loop output when no scope is set. - Clarify "session" semantics in `docs/book/src/security/tool-receipts.md`: the HMAC key is per daemon process (not per conversation or channel), generated at `start_channels` and rotated on restart. Add explicit "what receipts don't isolate" entries for cross-channel and background-delegate spawns.
1 parent 1776b6d commit a12c8d5

4 files changed

Lines changed: 281 additions & 11 deletions

File tree

crates/zeroclaw-channels/src/orchestrator/mod.rs

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3143,8 +3143,17 @@ async fn process_channel_message(
31433143
// Per-turn collector. `tool_execution::execute_one_tool` pushes
31443144
// `<tool_name>: <receipt>` here whenever a receipt is generated, so the
31453145
// orchestrator can render the trailing `Tool receipts:` block after the
3146-
// loop returns. Inert when `receipt_generator` is `None`.
3147-
let tool_receipts_collector: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
3146+
// loop returns. Wrapped in `Arc` so the same handle can be shared into
3147+
// `TOOL_LOOP_RECEIPT_CONTEXT` for subagent forwarding (#6182). Inert when
3148+
// `receipt_generator` is `None`.
3149+
let tool_receipts_collector: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
3150+
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3151+
let receipt_scope = ctx.receipt_generator.as_ref().map(|generator| {
3152+
zeroclaw_runtime::agent::tool_receipts::ReceiptScope {
3153+
generator: generator.clone(),
3154+
collector: std::sync::Arc::clone(&tool_receipts_collector),
3155+
}
3156+
});
31483157
let (llm_result, fallback_info) = scope_provider_fallback(async {
31493158
let llm_result = loop {
31503159
let loop_result = tokio::select! {
@@ -3157,6 +3166,8 @@ async fn process_channel_message(
31573166
.or_else(|| Some(msg.id.clone())),
31583167
zeroclaw_runtime::agent::loop_::TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
31593168
cost_tracking_context.clone(),
3169+
zeroclaw_runtime::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT.scope(
3170+
receipt_scope.clone(),
31603171
run_tool_call_loop(
31613172
active_provider.as_ref(),
31623173
&mut history,
@@ -3195,7 +3206,8 @@ async fn process_channel_message(
31953206
// call site reflects that coupling explicitly.
31963207
ctx.receipt_generator
31973208
.as_ref()
3198-
.map(|_| &tool_receipts_collector),
3209+
.map(|_| tool_receipts_collector.as_ref()),
3210+
),
31993211
),
32003212
),
32013213
),
@@ -3479,6 +3491,9 @@ async fn process_channel_message(
34793491
);
34803492
// Build the trailing `Tool receipts:` block from the per-turn
34813493
// collector. Empty when receipts are disabled or no tool ran.
3494+
// Includes receipts from delegate sub-agents because the same
3495+
// `Arc<Mutex<Vec<String>>>` is forwarded via
3496+
// `TOOL_LOOP_RECEIPT_CONTEXT` into sub-loops (see #6182).
34823497
let receipts_block = if ctx.show_receipts_in_response {
34833498
let receipts = tool_receipts_collector
34843499
.lock()
@@ -7604,6 +7619,136 @@ BTC is currently around $65,000 based on latest tool output."#
76047619
);
76057620
}
76067621

7622+
#[tokio::test]
7623+
async fn process_channel_message_disabled_receipt_generator_emits_no_receipts_anywhere() {
7624+
// Strict #6182 acceptance criterion: enabled=false must emit no
7625+
// receipt anywhere — not in any sent message, not in the model's
7626+
// view of conversation history. `receipt_generator: None` is the
7627+
// wire-level reflection of `[agent.tool_receipts] enabled = false`.
7628+
// Distinct from the show_in_response=false test above (which keeps
7629+
// the generator on but suppresses the trailing block); this one
7630+
// proves nothing is signed in the first place.
7631+
let channel_impl = Arc::new(RecordingChannel::default());
7632+
let channel: Arc<dyn Channel> = channel_impl.clone();
7633+
7634+
let mut channels_by_name = HashMap::new();
7635+
channels_by_name.insert(channel.name().to_string(), channel);
7636+
7637+
let runtime_ctx = Arc::new(ChannelRuntimeContext {
7638+
channels_by_name: Arc::new(channels_by_name),
7639+
provider: Arc::new(ToolCallingProvider),
7640+
default_provider: Arc::new("test-provider".to_string()),
7641+
memory: Arc::new(NoopMemory),
7642+
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
7643+
observer: Arc::new(NoopObserver),
7644+
system_prompt: Arc::new("test-system-prompt".to_string()),
7645+
model: Arc::new("test-model".to_string()),
7646+
temperature: 0.0,
7647+
auto_save_memory: false,
7648+
max_tool_iterations: 10,
7649+
min_relevance_score: 0.0,
7650+
conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
7651+
std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
7652+
))),
7653+
pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7654+
provider_cache: Arc::new(Mutex::new(HashMap::new())),
7655+
route_overrides: Arc::new(Mutex::new(HashMap::new())),
7656+
api_key: None,
7657+
api_url: None,
7658+
reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
7659+
provider_runtime_options: zeroclaw_providers::ProviderRuntimeOptions::default(),
7660+
workspace_dir: Arc::new(std::env::temp_dir()),
7661+
prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
7662+
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7663+
interrupt_on_new_message: InterruptOnNewMessageConfig {
7664+
telegram: false,
7665+
slack: false,
7666+
discord: false,
7667+
mattermost: false,
7668+
matrix: false,
7669+
},
7670+
non_cli_excluded_tools: Arc::new(Vec::new()),
7671+
autonomy_level: AutonomyLevel::Full,
7672+
tool_call_dedup_exempt: Arc::new(Vec::new()),
7673+
multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
7674+
media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
7675+
transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
7676+
hooks: None,
7677+
model_routes: Arc::new(Vec::new()),
7678+
query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
7679+
ack_reactions: true,
7680+
show_tool_calls: true,
7681+
session_store: None,
7682+
approval_manager: Arc::new(ApprovalManager::for_non_interactive(&{
7683+
let mut autonomy = zeroclaw_config::schema::AutonomyConfig::default();
7684+
autonomy.level = zeroclaw_config::autonomy::AutonomyLevel::Full;
7685+
autonomy.auto_approve.push("mock_price".to_string());
7686+
autonomy
7687+
})),
7688+
activated_tools: None,
7689+
cost_tracking: None,
7690+
pacing: zeroclaw_config::schema::PacingConfig::default(),
7691+
max_tool_result_chars: 0,
7692+
context_token_budget: 0,
7693+
debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
7694+
Duration::ZERO,
7695+
)),
7696+
receipt_generator: None,
7697+
show_receipts_in_response: false,
7698+
});
7699+
7700+
process_channel_message(
7701+
runtime_ctx.clone(),
7702+
zeroclaw_api::channel::ChannelMessage {
7703+
id: "msg-1".to_string(),
7704+
sender: "alice".to_string(),
7705+
reply_target: "chat-42".to_string(),
7706+
content: "What is the BTC price now?".to_string(),
7707+
channel: "test-channel".to_string(),
7708+
timestamp: 1,
7709+
thread_ts: None,
7710+
interruption_scope_id: None,
7711+
attachments: vec![],
7712+
},
7713+
CancellationToken::new(),
7714+
)
7715+
.await;
7716+
7717+
let sent_messages = channel_impl.sent_messages.lock().await;
7718+
assert!(
7719+
!sent_messages.is_empty(),
7720+
"agent must still respond when receipts are disabled"
7721+
);
7722+
assert!(
7723+
!sent_messages.iter().any(|m| m.contains("zc-receipt-")),
7724+
"no zc-receipt- token must appear in any sent message when receipts are disabled, got {:?}",
7725+
sent_messages.as_slice()
7726+
);
7727+
assert!(
7728+
!sent_messages.iter().any(|m| m.contains("Tool receipts:")),
7729+
"no `Tool receipts:` block must be sent when receipts are disabled, got {:?}",
7730+
sent_messages.as_slice()
7731+
);
7732+
7733+
// Strict surface check: the model's view of conversation history must
7734+
// not carry a `[receipt: ` trailer either, otherwise an LLM trained
7735+
// on echoing receipts could leak signed-looking output even though
7736+
// nothing was actually signed.
7737+
let histories = runtime_ctx
7738+
.conversation_histories
7739+
.lock()
7740+
.unwrap_or_else(|e| e.into_inner());
7741+
for (_key, turns) in histories.iter() {
7742+
for msg in turns.iter() {
7743+
assert!(
7744+
!msg.content.contains("[receipt: "),
7745+
"no `[receipt: ` trailer must appear in conversation history when receipts are disabled, got: {}",
7746+
msg.content
7747+
);
7748+
}
7749+
}
7750+
}
7751+
76077752
#[tokio::test]
76087753
async fn process_channel_message_telegram_does_not_persist_tool_summary_prefix() {
76097754
let channel_impl = Arc::new(TelegramRecordingChannel::default());

crates/zeroclaw-runtime/src/agent/tool_receipts.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,23 @@ impl ReceiptGenerator {
120120
}
121121
}
122122

123+
/// Per-turn receipt forwarding scope, used to thread the generator and
124+
/// the per-turn collector through delegate sub-loops without changing the
125+
/// `Tool` trait signature. Mirrors the pattern used by
126+
/// `TOOL_LOOP_COST_TRACKING_CONTEXT`.
127+
#[derive(Clone)]
128+
pub struct ReceiptScope {
129+
pub generator: ReceiptGenerator,
130+
pub collector: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
131+
}
132+
133+
tokio::task_local! {
134+
/// Set by the orchestrator when `[agent.tool_receipts] enabled = true`.
135+
/// `DelegateTool` reads this to forward receipts into sub-agent tool loops
136+
/// so subagent tool calls land in the same per-turn collector.
137+
pub static TOOL_LOOP_RECEIPT_CONTEXT: Option<ReceiptScope>;
138+
}
139+
123140
/// Parse a receipt string into (timestamp, hash).
124141
/// Expected format: `zc-receipt-{timestamp}-{base64url_hash}`
125142
fn parse_receipt(receipt: &str) -> Option<(u64, &str)> {

crates/zeroclaw-runtime/src/tools/delegate.rs

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,18 @@ impl DelegateTool {
788788
}
789789
}
790790

791+
// Capture the current receipt scope so each spawned sub-agent task
792+
// re-enters it. `tokio::spawn` does not propagate task-locals, so
793+
// without this `execute_sync`'s `try_with` would resolve to `None`
794+
// inside the spawn and the parallel agents would run unsigned even
795+
// when the parent turn has receipts enabled. The collector is `Arc`'d
796+
// inside `ReceiptScope`, so all parallel agents push into the same
797+
// per-turn collector the orchestrator renders after the loop returns.
798+
let parent_receipt_scope = crate::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT
799+
.try_with(Clone::clone)
800+
.ok()
801+
.flatten();
802+
791803
// Spawn all agents concurrently
792804
let mut handles = Vec::with_capacity(agent_names.len());
793805
for agent_name in &agent_names {
@@ -804,6 +816,7 @@ impl DelegateTool {
804816
let agent_name = agent_name.clone();
805817
let prompt = prompt.to_string();
806818
let args_clone = args.clone();
819+
let receipt_scope = parent_receipt_scope.clone();
807820

808821
handles.push(tokio::spawn(async move {
809822
let inner = DelegateTool {
@@ -819,8 +832,13 @@ impl DelegateTool {
819832
cancellation_token,
820833
memory: None,
821834
};
822-
let result = Box::pin(inner.execute_sync(&agent_name, &prompt, &args_clone)).await;
823-
(agent_name, result)
835+
let agent_name_for_return = agent_name.clone();
836+
let result = crate::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT
837+
.scope(receipt_scope, async move {
838+
Box::pin(inner.execute_sync(&agent_name, &prompt, &args_clone)).await
839+
})
840+
.await;
841+
(agent_name_for_return, result)
824842
}));
825843
}
826844

@@ -1153,6 +1171,17 @@ impl DelegateTool {
11531171
let agentic_timeout_secs = agent_config
11541172
.agentic_timeout_secs
11551173
.unwrap_or(self.delegate_config.agentic_timeout_secs);
1174+
// Forward the per-turn receipt scope from the parent loop so subagent
1175+
// tool calls land in the same collector as the top-level turn. When
1176+
// receipts are disabled (or no scope is set, e.g. CLI / background
1177+
// delegate spawn) this resolves to `None` and the sub-loop runs
1178+
// unsigned, matching the parent.
1179+
let receipt_scope = crate::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT
1180+
.try_with(Clone::clone)
1181+
.ok()
1182+
.flatten();
1183+
let receipt_generator = receipt_scope.as_ref().map(|s| &s.generator);
1184+
let collected_receipts = receipt_scope.as_ref().map(|s| s.collector.as_ref());
11561185
let result = tokio::time::timeout(
11571186
Duration::from_secs(agentic_timeout_secs),
11581187
run_tool_call_loop(
@@ -1181,8 +1210,8 @@ impl DelegateTool {
11811210
0, // context_token_budget: 0 = disabled for subagents
11821211
None, // shared_budget: TODO thread from parent in future
11831212
None, // channel: delegate subagents don't support approval
1184-
None, // receipt_generator
1185-
None, // collected_receipts
1213+
receipt_generator,
1214+
collected_receipts,
11861215
),
11871216
)
11881217
.await;
@@ -1899,6 +1928,81 @@ mod tests {
18991928
);
19001929
}
19011930

1931+
#[tokio::test]
1932+
async fn execute_agentic_forwards_receipt_scope_into_subagent_loop() {
1933+
// Receipt forwarding through the delegate sub-loop is the activation
1934+
// pass for #6182's delegate.rs:1184 acceptance criterion. With
1935+
// `TOOL_LOOP_RECEIPT_CONTEXT` scoped, every sub-tool call inside the
1936+
// delegate must produce a receipt that lands in the same per-turn
1937+
// collector the parent passed in. Without the task-local read in
1938+
// `execute_sync` this test fails: the collector stays empty because
1939+
// the sub-loop runs unsigned with `None, None` for the receipt args.
1940+
use crate::agent::tool_receipts::{
1941+
ReceiptGenerator, ReceiptScope, TOOL_LOOP_RECEIPT_CONTEXT,
1942+
};
1943+
1944+
let config = agentic_config(vec!["echo_tool".to_string()], 10);
1945+
let tool = DelegateTool::new(HashMap::new(), None, test_security())
1946+
.with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1947+
1948+
let collector: Arc<std::sync::Mutex<Vec<String>>> =
1949+
Arc::new(std::sync::Mutex::new(Vec::new()));
1950+
let scope = ReceiptScope {
1951+
generator: ReceiptGenerator::new(),
1952+
collector: Arc::clone(&collector),
1953+
};
1954+
1955+
let provider = OneToolThenFinalProvider;
1956+
let result = TOOL_LOOP_RECEIPT_CONTEXT
1957+
.scope(Some(scope), async {
1958+
tool.execute_agentic("agentic", &config, &provider, "run", 0.2)
1959+
.await
1960+
})
1961+
.await
1962+
.unwrap();
1963+
1964+
assert!(
1965+
result.success,
1966+
"delegate sub-loop must complete: {result:?}"
1967+
);
1968+
let receipts = collector.lock().unwrap();
1969+
assert_eq!(
1970+
receipts.len(),
1971+
1,
1972+
"expected exactly one receipt for the single echo_tool sub-call, got: {:?}",
1973+
receipts.as_slice()
1974+
);
1975+
assert!(
1976+
receipts[0].starts_with("echo_tool: zc-receipt-"),
1977+
"sub-tool receipt must be tagged with the tool name and a zc-receipt- HMAC token, got: {}",
1978+
receipts[0]
1979+
);
1980+
}
1981+
1982+
#[tokio::test]
1983+
async fn execute_agentic_emits_no_receipts_when_scope_absent() {
1984+
// Backward-compat for callers without a scoped receipt context (CLI,
1985+
// background spawn that does not forward scope, tests). The sub-loop
1986+
// must run unsigned and the agent output must not carry a
1987+
// `[receipt: ` trailer.
1988+
let config = agentic_config(vec!["echo_tool".to_string()], 10);
1989+
let tool = DelegateTool::new(HashMap::new(), None, test_security())
1990+
.with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1991+
1992+
let provider = OneToolThenFinalProvider;
1993+
let result = tool
1994+
.execute_agentic("agentic", &config, &provider, "run", 0.2)
1995+
.await
1996+
.unwrap();
1997+
1998+
assert!(result.success);
1999+
assert!(
2000+
!result.output.contains("[receipt: "),
2001+
"no receipt trailer must appear in agent output when receipts are disabled, got: {}",
2002+
result.output
2003+
);
2004+
}
2005+
19022006
#[tokio::test]
19032007
async fn execute_agentic_propagates_provider_errors() {
19042008
let config = agentic_config(vec!["echo_tool".to_string()], 10);

0 commit comments

Comments
 (0)