返回 CodeWhale
core_call.rs
根目录 / crates / tui / src / extension_host / core_call.rs
1 //! `core/call`: an extension tool, while it handles a `tool/call`, asking the
2 //! core to run one of the core's tools for it.
3 //!
4 //! The call goes through the same gate as a call the model makes. The turn
5 //! loop serves it (`Engine::gate_nested_call`, source `Extension`): planning
6 //! (allow and deny lists, preparation, hooks, ask-rules, Auto-Review, repo
7 //! law, the authority envelope), then the approval card when one is needed,
8 //! then execution by the same machinery code mode uses
9 //! ([`CodemodeInvoker`]). Nothing the host sends decides any of it: not an
10 //! approval, not the card's text (Rust composes it, naming the extension and
11 //! the tool it is inside), not an argv or a URL, not the ticket's contents.
12 //!
13 //! **When it exists.** An invocation ticket is minted only for an extension
14 //! tool call that runs under a nested-call gate the turn loop is serving *for
15 //! that tool* (`NestedCallGate::extension`, set by the turn loop when the model
16 //! called the tool directly). A sub-agent's call, a tool run from inside
17 //! `execute_tools` (code mode hands nested specs no gate), a command, a timer,
18 //! activation code and every test without a turn have none, so their
19 //! `core/call`s are refused ("out of turn"). The ticket lives exactly as long
20 //! as the invocation: it is revoked when the `tool/call` ends (answered,
21 //! failed, timed out or dropped), when its owner is revoked and when its host
22 //! exits, and each of those withdraws the invocation's pending core calls and
23 //! any approval card one is waiting on.
24 //!
25 //! **What is refused outright** ([`refusal`], checked before planning on the
26 //! name the host sent and again by the turn loop on the name planning resolved
27 //! it to and the final, hook-rewritten input): everything code mode refuses
28 //! before its gate (`execute_tools`, interpreters, `agent`, `workflow`, `rlm`,
29 //! `request_user_input`, interactive shells, sandbox escalation, Computer Use
30 //! consent and scripts, MCP sign-in); any extension tool (no recursion); tool
31 //! search and tool-result retrieval; the memory writer and the tools that
32 //! change what the session may do or schedule work beyond it; and every MCP
33 //! tool, Computer Use included (not in v1: CURRENT_DECISIONS 26, the founder's
34 //! recorded default).
35 //!
36 //! **What prompts** ([`origin_approval`]): an extension's call needs approval
37 //! unless the tool is in the small read-only, workspace-local table
38 //! [`EXT_AUTO_ELIGIBLE`] and planning found nothing that asks; its approval
39 //! keys are scoped to the extension plugin build
40 //! (`approval_cache::extension_origin_approval_keys`), so a grant the user gave
41 //! the model never covers an extension's call and the reverse. Shell and
42 //! network calls force a prompt: a session grant is not consulted, and
43 //! Full Access still opens their card. Auto-Review and Never refuse them
44 //! (`resolve_approval_request_disposition`); explicit denials always win.
45 //!
46 //! **Caps.** Per invocation: 50 `core/call`s in all (the ticket's uses), 4 at
47 //! once (code mode's own cap), and one approval card at a time (the turn
48 //! loop serves one request at a time). Per host: the channel's 256 in-flight
49 //! host requests. A burst of invalid tickets ends the host (`ticket`).
50 //!
51 //! **Time.** The `tool/call` deadline is measured on the invocation's pausable
52 //! clock, which stops while a `core/call` waits on the gate, so a person
53 //! taking a minute on a card does not time the tool out. An approval is never
54 //! decided for the person: when the host cancels, the owner is revoked, the
55 //! host exits or the invocation ends, the wait is withdrawn and recorded
56 //! cancelled.
57 //!
58 //! Known limits:
59 //! * The refusal list names tools; a tool added later that changes the mode,
60 //! the posture or the permissions has to be added to [`REFUSED_NAMES`]
61 //! (a test fails when a name in it stops being a tool the core registers).
62 //! * A withdrawn call retires its card through the typed `ApprovalWithdrawn`
63 //! event on terminal and runtime clients. A later answer has no live waiter.
64 //! * Results come back as text (and structured JSON when the tool's content
65 //! was JSON); images and other rich blocks are dropped, as in code mode.
66 //! * One extension tool call at a time holds the turn's tool lock, so a
67 //! tool's `core/call`s run beside it, not through the lock.
68
69 use std::collections::HashMap;
70 use std::sync::{Arc, Mutex};
71 use std::time::Duration;
72
73 use codewhale_workflow_js::ToolCallResponse;
74 use serde_json::{Value, json};
75 use tokio_util::sync::CancellationToken;
76
77 use super::ManagerShared;
78 use super::protocol::{
79 ContentBlockWire, CoreCallParams, OwnerRef, RpcErrorWire, ToolResultWire, error_code,
80 };
81 use super::supervisor::HostRequestContext;
82 use super::ticket::{Grant, Presented, Ticket, TicketKind};
83 use super::tier::HostTier;
84 use crate::core::authority::{ToolCategory, get_tool_category_for_call};
85 use crate::tools::codemode::{
86 CodemodeInvoker, ExtensionCaller, NestedCallGate, NestedDecision, NestedFailure, PauseClock,
87 };
88 use crate::tools::spec::{ToolCapability, ToolContext, ToolSpec};
89
90 /// The protocol method an invocation ticket is good for.
91 pub(crate) const METHOD: &str = "core/call";
92 /// `core/call`s one invocation may make in all.
93 pub(crate) const MAX_CALLS_PER_INVOCATION: u32 = 50;
94 /// A backstop only: an invocation ends (and revokes its ticket) long before
95 /// this, and its own deadline pauses while a person decides.
96 const TICKET_TTL: Duration = Duration::from_secs(24 * 60 * 60);
97 /// Receipts attached to an extension tool's result metadata.
98 pub(crate) const RECEIPTS_ATTACHED: usize = 50;
99
100 /// Tools an extension may never reach through `core/call`, by lower-case
101 /// name, checked on the spelling the host sent and on the canonical action
102 /// alias planning resolves it to.
103 const REFUSED_NAMES: &[&str] = &[
104 // Discovery and retrieval: schema activation and spilled output are the
105 // turn's own.
106 "tool_search",
107 "tool_search_tool_regex",
108 "tool_search_tool_bm25",
109 "retrieve_tool_result",
110 // The memory writer.
111 "remember",
112 // What the session may do, or work scheduled beyond it.
113 "request_plugin_install",
114 "create_goal",
115 "update_goal",
116 "automation",
117 "send_later",
118 "start_mcp_server",
119 "start_registry_mcp_server",
120 ];
121
122 /// Read-only, workspace-local tools an extension's call may run without a
123 /// prompt, when planning also finds nothing that asks for one. Every other
124 /// tool an extension asks for needs approval. A test pins that each is a
125 /// registered, read-only, auto-approved tool.
126 pub(crate) const EXT_AUTO_ELIGIBLE: &[&str] =
127 &["read", "read_file", "list_dir", "file_search", "grep_files"];
128
129 /// Tools that run commands or reach the network and are not in the shell and
130 /// network categories by name: an extension's call of one forces a prompt too.
131 const FORCED_PROMPT_NAMES: &[&str] = &[
132 "web.run",
133 "git_fetch",
134 "finance",
135 "run_tests",
136 "run_verifiers",
137 "verify",
138 "harness",
139 ];
140
141 fn canonical(name: &str, input: &Value) -> String {
142 // Preserve the family's registered spelling until its action is resolved.
143 // Lowercasing `Git` first would hide `Git{action:"fetch"}` from policy.
144 let family = crate::tools::canonical_action::CANONICAL_ACTION_ALIASES
145 .iter()
146 .find(|(family, _, _)| family.eq_ignore_ascii_case(name))
147 .map_or(name, |(family, _, _)| *family);
148 crate::tools::canonical_action::canonical_action_alias(family, input).to_ascii_lowercase()
149 }
150
151 fn is_extension_tool(specs: &[Arc<dyn ToolSpec>], name: &str) -> bool {
152 specs
153 .iter()
154 .find(|spec| spec.name().eq_ignore_ascii_case(name))
155 .is_some_and(|spec| spec.extension_caller().is_some())
156 }
157
158 /// Whether the core refuses an extension's call of `name` outright, and why:
159 /// code mode's own refusals plus the extension list (module docs). Pure over
160 /// the tool snapshot, so the turn loop can run it again on what planning
161 /// resolved. `ExtraRefusal`'s shape.
162 pub(crate) fn refusal(specs: &[Arc<dyn ToolSpec>], name: &str, input: &Value) -> Option<String> {
163 if let Some(note) = crate::tools::codemode::refusal_before_gate(name, input, true) {
164 return Some(note);
165 }
166 let lower = name.to_ascii_lowercase();
167 let resolved = canonical(name, input);
168 if is_extension_tool(specs, name) || is_extension_tool(specs, &resolved) {
169 return Some(format!(
170 "`{name}` is an extension tool; an extension's core/call cannot reach another extension's tool (or its own)"
171 ));
172 }
173 if crate::mcp::McpPool::is_mcp_tool(&lower) || crate::mcp::McpPool::is_mcp_tool(&resolved) {
174 return Some(format!(
175 "`{name}` is an MCP tool (Computer Use included); extensions cannot call MCP tools through core/call"
176 ));
177 }
178 let family = |candidate: &str| {
179 REFUSED_NAMES.contains(&candidate)
180 || crate::core::engine::tool_catalog::is_tool_search_tool(candidate)
181 || candidate.starts_with("automation_")
182 };
183 if family(&lower) || family(&resolved) {
184 return Some(format!(
185 "`{name}` is not available to an extension's core/call (tool search and retrieval, the memory writer, and tools that change what the session may do or schedule work stay the model's own)"
186 ));
187 }
188 None
189 }
190
191 /// How an extension's call of a tool differs from the model's, after planning
192 /// has decided what the model's call would need.
193 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
194 pub(crate) enum OriginApproval {
195 /// Planning's decision stands (the tool is in [`EXT_AUTO_ELIGIBLE`] and
196 /// nothing asked for approval).
197 Unchanged,
198 /// Needs approval; an extension-scoped session grant may satisfy it.
199 Prompt,
200 /// A shell or network call: needs a prompt that no grant, and no posture
201 /// that cannot open one, may satisfy.
202 ForcePrompt,
203 }
204
205 /// The approval an extension's call of `name` needs. `planned_requires_approval`
206 /// is what planning decided without regard to origin (a read outside the
207 /// workspace, a hook's ask or repo law all say yes there, and stay yes).
208 pub(crate) fn origin_approval(
209 name: &str,
210 input: &Value,
211 planned_requires_approval: bool,
212 spec: Option<&dyn ToolSpec>,
213 ) -> OriginApproval {
214 use crate::tools::execution_envelope::{CallClass, classify_call};
215
216 let lower = name.to_ascii_lowercase();
217 let resolved = canonical(name, input);
218 let forced = |candidate: &str| {
219 matches!(
220 get_tool_category_for_call(candidate, input),
221 ToolCategory::Shell | ToolCategory::Network
222 ) || FORCED_PROMPT_NAMES.contains(&candidate)
223 };
224 // Names cover core meta-tools; the registered capability and concrete
225 // execution classification also cover tools added without a name-list row.
226 let reaches_or_executes = spec.is_some_and(|spec| {
227 spec.capabilities().contains(&ToolCapability::Network)
228 || matches!(
229 classify_call(spec.name(), input, spec),
230 CallClass::VerificationFilter
231 | CallClass::UnboundedVerification
232 | CallClass::BoundedFetch
233 | CallClass::Executes
234 | CallClass::Reaches
235 )
236 });
237 if forced(&lower) || forced(&resolved) || reaches_or_executes {
238 OriginApproval::ForcePrompt
239 } else if EXT_AUTO_ELIGIBLE.contains(&resolved.as_str()) && !planned_requires_approval {
240 OriginApproval::Unchanged
241 } else {
242 OriginApproval::Prompt
243 }
244 }
245
246 /// One extension tool call's standing to ask the core for things: its ticket
247 /// and the machinery the asks run on. Lives from the `tool/call`'s start to
248 /// its end.
249 pub(crate) struct Invocation {
250 tier: HostTier,
251 host_generation: u64,
252 owner: OwnerRef,
253 scope: Option<super::protocol::EntryRef>,
254 plugins: Option<Arc<crate::plugins::PluginRegistry>>,
255 content_hash: String,
256 calls: CodemodeInvoker,
257 /// Fires when the invocation ends or is revoked: withdraws every
258 /// `core/call` still waiting or running for it.
259 cancel: CancellationToken,
260 }
261
262 /// The tickets and invocations of one manager.
263 #[derive(Default)]
264 pub(crate) struct CoreCalls {
265 pub(super) tickets: super::ticket::TicketTable,
266 invocations: Mutex<HashMap<String, Arc<Invocation>>>,
267 }
268
269 impl CoreCalls {
270 /// Start an invocation for the `tool/call` `call_id` of `owner`'s tool
271 /// running on `tier`'s host process `host_generation`, if `gate` is the
272 /// nested-call gate the turn loop serves for exactly this tool
273 /// (`expected`). `None` otherwise: no ticket, so no `core/call`.
274 #[cfg(test)]
275 pub(crate) fn begin(
276 self: &Arc<Self>,
277 tier: HostTier,
278 host_generation: u64,
279 owner: &OwnerRef,
280 call_id: &str,
281 expected: &ExtensionCaller,
282 context: &ToolContext,
283 gate: &NestedCallGate,
284 ) -> Option<InvocationGuard> {
285 self.begin_scoped(
286 tier,
287 host_generation,
288 owner,
289 call_id,
290 expected,
291 context,
292 gate,
293 None,
294 String::new(),
295 )
296 }
297 #[allow(clippy::too_many_arguments)]
298 pub(crate) fn begin_scoped(
299 self: &Arc<Self>,
300 tier: HostTier,
301 host_generation: u64,
302 owner: &OwnerRef,
303 call_id: &str,
304 expected: &ExtensionCaller,
305 context: &ToolContext,
306 gate: &NestedCallGate,
307 scope: Option<super::protocol::EntryRef>,
308 content_hash: String,
309 ) -> Option<InvocationGuard> {
310 let (caller, specs) = gate.extension()?;
311 if caller != expected {
312 return None;
313 }
314 let calls = CodemodeInvoker::for_extension(
315 specs.to_vec(),
316 context.clone(),
317 gate.clone(),
318 call_id.to_string(),
319 refusal,
320 );
321 let ticket = self.tickets.mint(Grant {
322 kind: TicketKind::Invocation,
323 tier,
324 host_generation,
325 owner: owner.clone(),
326 method: METHOD,
327 target: json!({ "call_id": call_id }),
328 ttl: TICKET_TTL,
329 uses: MAX_CALLS_PER_INVOCATION,
330 });
331 let invocation = Arc::new(Invocation {
332 tier,
333 host_generation,
334 owner: owner.clone(),
335 scope,
336 plugins: context.plugin_registry.clone(),
337 content_hash,
338 calls,
339 cancel: CancellationToken::new(),
340 });
341 self.invocations
342 .lock()
343 .expect("invocations lock")
344 .insert(ticket.expose().to_string(), Arc::clone(&invocation));
345 Some(InvocationGuard {
346 core_calls: Arc::clone(self),
347 ticket,
348 invocation,
349 })
350 }
351
352 /// The invocation `ticket` belongs to, still live.
353 fn invocation(&self, ticket: &str) -> Option<Arc<Invocation>> {
354 self.invocations
355 .lock()
356 .expect("invocations lock")
357 .get(ticket)
358 .cloned()
359 }
360
361 /// Revoke and withdraw whatever `keep` says to drop.
362 fn revoke_where(&self, drop_it: impl Fn(&Invocation) -> bool) {
363 let gone: Vec<Arc<Invocation>> = {
364 let mut invocations = self.invocations.lock().expect("invocations lock");
365 let keys: Vec<String> = invocations
366 .iter()
367 .filter(|(_, invocation)| drop_it(invocation))
368 .map(|(key, _)| key.clone())
369 .collect();
370 keys.iter()
371 .filter_map(|key| invocations.remove(key))
372 .collect()
373 };
374 for invocation in gone {
375 invocation.cancel.cancel();
376 }
377 }
378
379 /// `plugin_id`'s owner was revoked: every ticket it holds is revoked and
380 /// every core call it has pending is withdrawn.
381 pub(crate) fn revoke_owner(&self, plugin_id: &str) {
382 self.tickets.revoke_owner(plugin_id);
383 self.revoke_where(|invocation| invocation.owner.plugin_id == plugin_id);
384 }
385
386 pub(crate) fn revoke_scope(&self, plugin_id: &str, scope: &super::protocol::EntryRef) {
387 self.revoke_where(|invocation| {
388 invocation.owner.plugin_id == plugin_id && invocation.scope.as_ref() == Some(scope)
389 });
390 }
391
392 pub(crate) fn revoke_attachment(&self, id: u64) {
393 self.revoke_where(|invocation| {
394 invocation
395 .plugins
396 .as_ref()
397 .and_then(|plugins| plugins.caller_selection())
398 .is_some_and(|selection| selection.attachment_id == id)
399 });
400 }
401
402 /// One host process exited.
403 pub(crate) fn revoke_host(&self, tier: HostTier, host_generation: u64) {
404 self.tickets.revoke_host(tier, host_generation);
405 self.revoke_where(|invocation| {
406 invocation.tier == tier && invocation.host_generation == host_generation
407 });
408 }
409
410 /// How many tickets are live.
411 #[cfg(test)]
412 pub(crate) fn live_tickets(&self) -> usize {
413 self.tickets.live()
414 }
415
416 fn end(&self, ticket: &Ticket) {
417 self.tickets.revoke(ticket);
418 if let Some(invocation) = self
419 .invocations
420 .lock()
421 .expect("invocations lock")
422 .remove(ticket.expose())
423 {
424 invocation.cancel.cancel();
425 }
426 }
427
428 /// Redeem `params` presented by `tier`'s host process `host_generation`
429 /// and run the call. An invalid presentation is a refusal, and a burst of
430 /// them a violation `cx` reports.
431 pub(crate) async fn serve(
432 &self,
433 shared: &ManagerShared,
434 tier: HostTier,
435 host_generation: u64,
436 params: CoreCallParams,
437 cx: HostRequestContext,
438 ) -> Result<Value, RpcErrorWire> {
439 let refuse = |code: i64, message: String| RpcErrorWire {
440 code,
441 message,
442 data: None,
443 };
444 let redeemed = self.tickets.redeem(&Presented {
445 ticket: &params.ticket,
446 kind: TicketKind::Invocation,
447 tier,
448 host_generation,
449 owner: &params.owner,
450 method: METHOD,
451 target: None,
452 });
453 if let Err(refused) = redeemed {
454 if refused.violation {
455 cx.violation(
456 "too many invalid core/call tickets from one host process".to_string(),
457 );
458 }
459 return Err(refuse(
460 error_code::REFUSED,
461 refused.reason.describe().to_string(),
462 ));
463 }
464 // The redemption proved the ticket is live and ours; the owner must
465 // still be the current one of this tier.
466 let live = shared
467 .registry
468 .lock()
469 .expect("registry lock")
470 .tier_of(&params.owner)
471 == Some(tier);
472 let Some(invocation) = self.invocation(&params.ticket).filter(|_| live) else {
473 return Err(refuse(
474 error_code::REFUSED,
475 "the invocation this core/call belongs to has ended".to_string(),
476 ));
477 };
478 shared
479 .check_selection(
480 invocation
481 .plugins
482 .as_ref()
483 .and_then(|plugins| plugins.caller_selection()),
484 invocation.plugins.as_deref(),
485 &params.owner.plugin_id,
486 &invocation.content_hash,
487 invocation.scope.as_ref(),
488 )
489 .map_err(|message| refuse(error_code::REFUSED, message))?;
490 // Withdrawn when the host cancels this request, its owner is revoked,
491 // the host exits (`cx.cancel`), or the invocation ends.
492 let withdraw = invocation.cancel.child_token();
493 {
494 let (withdraw, host) = (withdraw.clone(), cx.cancel.clone());
495 tokio::spawn(async move {
496 tokio::select! {
497 () = host.cancelled() => withdraw.cancel(),
498 () = withdraw.cancelled() => {}
499 }
500 });
501 }
502 let outcome = invocation
503 .calls
504 .call(params.name, params.input, Some(&withdraw))
505 .await;
506 shared
507 .check_selection(
508 invocation
509 .plugins
510 .as_ref()
511 .and_then(|plugins| plugins.caller_selection()),
512 invocation.plugins.as_deref(),
513 &params.owner.plugin_id,
514 &invocation.content_hash,
515 invocation.scope.as_ref(),
516 )
517 .map_err(|message| refuse(error_code::REFUSED, message))?;
518 let result = match outcome {
519 Ok(response) => Ok(wire_from_response(response)),
520 Err(NestedFailure::Rejected { decision, message }) => Err(refuse(
521 if decision == NestedDecision::Denied {
522 error_code::DENIED
523 } else {
524 error_code::REFUSED
525 },
526 message,
527 )),
528 Err(NestedFailure::Unavailable(message)) => Err(refuse(
529 if withdraw.is_cancelled() {
530 error_code::CANCELLED
531 } else {
532 error_code::NOT_AVAILABLE
533 },
534 message,
535 )),
536 };
537 // Stop the forwarder when the call is over.
538 withdraw.cancel();
539 result.map(|wire| serde_json::to_value(wire).expect("wire results serialize"))
540 }
541 }
542
543 /// An extension tool's answer to its `core/call`: the tool's text (and its
544 /// structured JSON when the content was JSON), bounded as code mode bounds it.
545 pub(crate) fn wire_from_response(response: ToolCallResponse) -> ToolResultWire {
546 if !response.ok {
547 let text = match response.result {
548 Value::String(text) => text,
549 other => other.to_string(),
550 };
551 return ToolResultWire {
552 content: vec![ContentBlockWire::Text { text }],
553 is_error: true,
554 structured: None,
555 };
556 }
557 let content = response
558 .result
559 .get("content")
560 .cloned()
561 .unwrap_or(Value::Null);
562 let (mut text, structured) = match content {
563 Value::String(text) => (text, None),
564 other => (other.to_string(), Some(other)),
565 };
566 if let Some(cut) = response
567 .result
568 .get("truncated")
569 .filter(|truncated| !truncated.is_null())
570 {
571 let number = |key: &str| cut.get(key).and_then(Value::as_u64).unwrap_or(0);
572 text.push_str(&format!(
573 "\n[output truncated: {} bytes in all, the first {} are shown]",
574 number("original_bytes"),
575 number("kept_bytes")
576 ));
577 }
578 ToolResultWire {
579 content: vec![ContentBlockWire::Text { text }],
580 is_error: false,
581 structured,
582 }
583 }
584
585 /// Holds an invocation open. Dropping it (the `tool/call` ended or its future
586 /// was dropped) revokes the ticket and withdraws whatever is still pending.
587 pub(crate) struct InvocationGuard {
588 core_calls: Arc<CoreCalls>,
589 ticket: Ticket,
590 invocation: Arc<Invocation>,
591 }
592
593 impl InvocationGuard {
594 /// The ticket id to put in this call's `tool/call` (and nowhere else).
595 pub(crate) fn ticket(&self) -> &str {
596 self.ticket.expose()
597 }
598
599 /// The clock the `tool/call` deadline runs on: paused while one of this
600 /// invocation's core calls waits on the gate.
601 pub(crate) fn clock(&self) -> Arc<Mutex<PauseClock>> {
602 self.invocation.calls.clock()
603 }
604
605 /// The receipts of the core calls made so far, bounded, for the tool's
606 /// result metadata; `None` when it made none.
607 pub(crate) fn receipts(&self) -> Option<Value> {
608 let receipts = self.invocation.calls.receipts_json(RECEIPTS_ATTACHED);
609 (receipts["total"].as_u64().unwrap_or(0) > 0).then_some(receipts)
610 }
611 }
612
613 impl Drop for InvocationGuard {
614 fn drop(&mut self) {
615 self.core_calls.end(&self.ticket);
616 }
617 }
618
618 lines RUST