1mod checkpoint;
4pub mod cli;
5mod context;
6mod control;
7mod delegation;
8mod developer;
9pub(crate) mod files;
10mod mcp;
11mod mcp_client;
12pub mod policy;
13mod terminal;
14mod tools;
15use policy::{ApprovalHandler, ApprovalRequest, Authorization, DenyApprovals, RuntimePolicy};
16
17use crate::{
18 agent_model::{FinishReason, ModelMessage, ModelRegistry, ModelRequest, ModelTool},
19 agent_resource::{Capability, LoadedAgentResource, ResourceDirectory},
20 agent_store::{
21 AccountingOverflow, AgentRun, AgentRunStore, BudgetDecision, FailureReason, LimitReason,
22 RunBudgets, RunOutcome, ToolOutcome, WorkingStateStatus,
23 },
24 app_store::SqliteAppStore,
25 config::Config,
26 tool_binding::{self, HostToolAuthority, ResolvedToolResource},
27 traits::{
28 ToolContext, ToolExecutionError, ToolExecutionErrorClass, ToolRegistry, ToolRuntimeDispatch,
29 },
30};
31use anyhow::{ensure, Context, Result};
32use serde_json::{json, Value};
33use sha2::{Digest, Sha256};
34use std::{
35 collections::BTreeMap,
36 path::{Path, PathBuf},
37 sync::Arc,
38 time::Duration,
39};
40use tokio::sync::watch;
41
42const MAX_TOOL_RESULT_BYTES: usize = 1024 * 1024;
43
44fn canonical_json(value: Value) -> Value {
45 match value {
46 Value::Object(object) => Value::Object(
47 object
48 .into_iter()
49 .map(|(key, value)| (key, canonical_json(value)))
50 .collect(),
51 ),
52 Value::Array(values) => Value::Array(values.into_iter().map(canonical_json).collect()),
53 value => value,
54 }
55}
56
57fn recoverable_observation(error: &ToolExecutionError) -> Result<(Value, String)> {
58 let code = serde_json::to_string(error.code())?;
59 let message = serde_json::to_string(error.message())?;
60 let content = format!(
61 "{{\"ok\":false,\"error\":{{\"type\":\"recoverable_tool_error\",\"code\":{code},\"message\":{message}}}}}"
62 );
63 Ok((serde_json::from_str(&content)?, content))
64}
65
66fn repetition_key(
67 tool: &dyn crate::traits::Tool,
68 effective_arguments: Value,
69 code: &str,
70) -> Result<String> {
71 let metadata = tool
72 .binding_metadata()
73 .context("recoverable tool identity is unavailable")?;
74 let implementation_id = metadata
75 .get("implementation_id")
76 .and_then(Value::as_str)
77 .context("recoverable tool implementation identity is unavailable")?;
78 let implementation_version = metadata
79 .get("implementation_version")
80 .and_then(Value::as_str)
81 .context("recoverable tool implementation version is unavailable")?;
82 let identity = canonical_json(json!({
83 "version": 1,
84 "tool_identity": {
85 "name": tool.name(),
86 "implementation_id": implementation_id,
87 "implementation_version": implementation_version,
88 "resource_version": metadata
89 .get("resource_version")
90 .or_else(|| metadata.get("binding_version"))
91 .cloned()
92 .unwrap_or(Value::Null),
93 },
94 "effective_arguments": effective_arguments,
95 "error_code": code,
96 }));
97 Ok(format!(
98 "{:x}",
99 Sha256::digest(serde_json::to_vec(&identity)?)
100 ))
101}
102
103fn classified_tool_error(error: &anyhow::Error) -> Option<&ToolExecutionError> {
104 error
105 .chain()
106 .find_map(|source| source.downcast_ref::<ToolExecutionError>())
107}
108
109struct RestoredRequest {
110 request: ModelRequest,
111 logical_indexes: Option<Vec<u64>>,
112 source_message_count: Option<u64>,
113 source_exchange_count: Option<u64>,
114}
115
116pub(crate) fn run_budgets(resource: &LoadedAgentResource) -> RunBudgets {
117 let limits = &resource.definition.agent.execution;
118 RunBudgets {
119 max_turns: Some(u64::from(limits.max_turns)),
120 timeout_seconds: Some(limits.timeout_seconds),
121 max_total_tokens: limits.max_total_tokens,
122 max_tool_calls: limits.max_tool_calls,
123 max_context_bytes: limits.max_context_bytes,
124 }
125}
126const MAX_TOOL_CALLS_PER_TURN: usize = 32;
127
128pub fn workspace_id(root: &Path) -> Result<String> {
131 let root = root.canonicalize()?;
132 Ok(format!(
133 "local-{:x}",
134 Sha256::digest(root.as_os_str().as_encoded_bytes())
135 ))
136}
137
138pub(crate) fn static_tool_identity(
141 config: &Config,
142 root: &Path,
143 resources: &BTreeMap<String, LoadedAgentResource>,
144 bindings: &BTreeMap<String, ResolvedToolResource>,
145 policy: &RuntimePolicy,
146) -> Result<Value> {
147 let mut builtins = tools::metadata_registry(config)?;
148 developer::register(&mut builtins, root)?;
149 builtins.register(Box::new(delegation::InvokeTool));
150 control::register(&mut builtins);
151
152 let mut agents = serde_json::Map::new();
153 for (agent_name, resource) in resources {
154 let agent = &resource.definition.agent;
155 let mut advertised = Vec::new();
156 for name in &agent.tools {
157 if let Some(binding) = bindings.get(name) {
158 ensure!(
159 policy.authorize(&agent.permissions, &binding.binding.capabilities)
160 != Authorization::Denied,
161 "tool capability is not permitted by agent and host policy"
162 );
163 advertised.push(json!({
164 "name": name,
165 "description": binding.binding.description,
166 "parameters": binding.binding.public_schema,
167 "resource_version": binding.resource_version,
168 "binding": binding.binding,
169 }));
170 continue;
171 }
172 let tool = builtins
173 .find(name)
174 .context("unsupported runtime tool declaration")?;
175 let capabilities = tool
176 .capabilities()
177 .context("tool capability metadata is unavailable")?;
178 if !matches!(tool.runtime_dispatch(), ToolRuntimeDispatch::RunControl(_)) {
179 ensure!(
180 policy.authorize(&agent.permissions, &capabilities) != Authorization::Denied,
181 "tool capability is not permitted by agent and host policy"
182 );
183 }
184 let mut parameters = tool.parameters_schema();
185 if tool.runtime_dispatch() == ToolRuntimeDispatch::AgentDelegation {
186 parameters["properties"]["agent"]["enum"] = json!(agent.delegation.allow);
187 }
188 advertised.push(json!({
189 "name": name,
190 "description": tool.description(),
191 "parameters": parameters,
192 "binding": tool.binding_metadata(),
193 }));
194 }
195 agents.insert(agent_name.clone(), Value::Array(advertised));
196 }
197 Ok(Value::Object(agents))
198}
199
200#[derive(Clone)]
201pub struct AgentRuntime {
202 config: Arc<Config>,
203 root: PathBuf,
204 store: AgentRunStore,
205 tools: Arc<ToolRegistry>,
206 models: Arc<ModelRegistry>,
207 policy: RuntimePolicy,
208 approvals: Arc<dyn ApprovalHandler>,
209 resources: Arc<std::collections::BTreeMap<String, LoadedAgentResource>>,
210 tool_bindings: Arc<std::collections::BTreeMap<String, tool_binding::LoadedToolResource>>,
211 execution: Option<delegation::ExecutionContext>,
212 context_builder: Arc<dyn context::RunContextBuilder>,
213}
214impl AgentRuntime {
215 pub async fn new(mut config: Config, root: &Path, models: ModelRegistry) -> Result<Self> {
217 let root = root.canonicalize()?;
218 if config.db.path.is_relative() {
219 config.db.path = root.join(&config.db.path);
220 }
221 SqliteAppStore::initialize_config(&config).await?;
222 Self::from_initialized_config(config, root, models).await
223 }
224
225 pub(crate) async fn new_initialized(
226 mut config: Config,
227 root: &Path,
228 models: ModelRegistry,
229 ) -> Result<Self> {
230 let root = root.canonicalize()?;
231 if config.db.path.is_relative() {
232 config.db.path = root.join(&config.db.path);
233 }
234 Self::from_initialized_config(config, root, models).await
235 }
236
237 async fn from_initialized_config(
238 config: Config,
239 root: PathBuf,
240 models: ModelRegistry,
241 ) -> Result<Self> {
242 let app = SqliteAppStore::connect(&config).await?;
243 let store = app.agent_runs(&workspace_id(&root)?)?;
244 let mut tools = tools::registry(&config).await?;
245 developer::register(&mut tools, &root)?;
246 tools.register(Box::new(delegation::InvokeTool));
247 ensure!(
248 [control::BLOCKED, control::REQUEST_USER_INPUT]
249 .iter()
250 .all(|name| tools.find(name).is_none()),
251 "runtime control tool name is reserved"
252 );
253 control::register(&mut tools);
254 Ok(Self {
255 config: Arc::new(config),
256 root,
257 store,
258 tools: Arc::new(tools),
259 models: Arc::new(models),
260 resources: Arc::new(Default::default()),
261 tool_bindings: Arc::new(Default::default()),
262 execution: None,
263 context_builder: Arc::new(context::CompatibilityContextBuilder),
264 policy: RuntimePolicy::default(),
265 approvals: Arc::new(DenyApprovals),
266 })
267 }
268
269 pub fn with_policy(
273 mut self,
274 policy: RuntimePolicy,
275 approvals: Arc<dyn ApprovalHandler>,
276 ) -> Self {
277 self.policy = policy;
278 self.approvals = approvals;
279 self
280 }
281
282 pub async fn with_tool_bindings(self, directories: &[ResourceDirectory]) -> Result<Self> {
285 let mut authority = HostToolAuthority::new(&self.root, vec![Capability::ReadOnly])?;
286 authority.enroll_path(&self.root)?;
287 let sources = self
288 .config
289 .connectors
290 .filesystem
291 .keys()
292 .map(|name| format!("filesystem:{name}"))
293 .chain(
294 self.config
295 .connectors
296 .git
297 .keys()
298 .map(|name| format!("git:{name}")),
299 )
300 .chain(
301 self.config
302 .connectors
303 .s3
304 .keys()
305 .map(|name| format!("s3:{name}")),
306 )
307 .chain(
308 self.config
309 .connectors
310 .script
311 .keys()
312 .map(|name| format!("script:{name}")),
313 );
314 for source in sources {
315 authority.enroll_source(source)?;
316 }
317 let catalog = tool_binding::core_catalog()?;
318 self.bind_tool_resources(directories, &catalog, Arc::new(authority))
319 .await
320 }
321
322 pub async fn with_tool_binding_catalog(
324 self,
325 directories: &[ResourceDirectory],
326 catalog: &tool_binding::ToolImplementationCatalog,
327 authority: Arc<HostToolAuthority>,
328 ) -> Result<Self> {
329 self.bind_tool_resources(directories, catalog, authority)
330 .await
331 }
332
333 async fn bind_tool_resources(
334 mut self,
335 directories: &[ResourceDirectory],
336 catalog: &tool_binding::ToolImplementationCatalog,
337 authority: Arc<HostToolAuthority>,
338 ) -> Result<Self> {
339 let loaded = tool_binding::load_resources(directories, &self.config)?;
340 if loaded.is_empty() {
341 return Ok(self);
342 }
343 ensure!(
344 loaded.keys().all(|name| !control::is_reserved(name)),
345 "runtime control tool name is reserved"
346 );
347 let local = loaded
348 .iter()
349 .filter(|(_, resource)| {
350 tool_binding::mcp_reference(&resource.definition.tool.implementation).is_none()
351 })
352 .map(|(name, resource)| (name.clone(), resource.clone()))
353 .collect();
354 let bindings = tool_binding::bind_resources(&local, catalog, authority).await?;
355 self.tool_bindings = Arc::new(loaded);
356 let tools = Arc::get_mut(&mut self.tools).context("runtime tool registry is shared")?;
357 for tool in bindings.tools() {
358 ensure!(
359 tools.find(tool.name()).is_none(),
360 "tool binding '{}' conflicts with an existing runtime tool",
361 tool.name()
362 );
363 }
364 for tool in bindings.into_tools() {
365 tools.register(tool);
366 }
367 Ok(self)
368 }
369
370 pub fn store(&self) -> &AgentRunStore {
371 &self.store
372 }
373
374 pub async fn run(
377 &self,
378 resource: &LoadedAgentResource,
379 input: &str,
380 cancel: watch::Receiver<bool>,
381 ) -> Result<AgentRun> {
382 ensure!(
383 !input.trim().is_empty() && input.len() <= 64 * 1024,
384 "input must contain 1–65536 bytes"
385 );
386 let agent = &resource.definition.agent;
387 let run = self
388 .store
389 .create_run_with_budgets(
390 &agent.name,
391 &resource.version,
392 &agent.model,
393 input,
394 run_budgets(resource),
395 )
396 .await?;
397 self.execute_existing_run(&run, resource, cancel).await
398 }
399
400 pub(crate) async fn execute_existing_run(
402 &self,
403 run: &AgentRun,
404 resource: &LoadedAgentResource,
405 cancel: watch::Receiver<bool>,
406 ) -> Result<AgentRun> {
407 let agent = &resource.definition.agent;
408 ensure!(
409 run.workspace_id == workspace_id(&self.root)?
410 && run.agent_name == agent.name
411 && run.agent_version == resource.version
412 && run.model == agent.model
413 && run.lifecycle == crate::agent_store::RunLifecycle::Active,
414 "linked run identity does not match the configured runtime"
415 );
416 let files = match files::acquire(&self.root, &run.id) {
417 Ok(files) => files,
418 Err(_) => {
419 self.store
420 .finish_run(
421 &run.id,
422 RunOutcome::FailedWithReason {
423 code: FailureReason::RecoveryRejected,
424 error: "run ownership is unavailable".into(),
425 },
426 )
427 .await?;
428 return self
429 .store
430 .get_run(&run.id)
431 .await?
432 .context("run disappeared");
433 }
434 };
435 let mut runtime = self.clone();
436 runtime.execution = Some(delegation::ExecutionContext {
437 deadline: run.created_at.saturating_add(
438 i64::try_from(agent.execution.timeout_seconds)
439 .unwrap_or(i64::MAX)
440 .saturating_mul(1000),
441 ),
442 ancestry: vec![agent.name.clone()],
443 });
444 runtime.drive(run, resource, None, 0, cancel, &files).await
445 }
446
447 async fn drive(
448 &self,
449 run: &AgentRun,
450 resource: &LoadedAgentResource,
451 request: Option<RestoredRequest>,
452 start_turn: u32,
453 mut cancel: watch::Receiver<bool>,
454 files: &files::RunFiles,
455 ) -> Result<AgentRun> {
456 let remaining = match self.remaining_time(run, resource) {
457 Ok(remaining) => remaining,
458 Err(error) => {
459 self.store
460 .finish_run(
461 &run.id,
462 RunOutcome::FailedWithReason {
463 code: FailureReason::RecoveryRejected,
464 error: error.to_string(),
465 },
466 )
467 .await?;
468 return self
469 .store
470 .get_run(&run.id)
471 .await?
472 .context("run disappeared");
473 }
474 };
475 let mut pending_suspension = None;
476 let outcome = tokio::select! {
477 biased;
478 _ = cancelled(&mut cancel) => RunOutcome::Cancelled,
479 result = tokio::time::timeout(remaining, self.execute(run, resource, &run.input, request, start_turn, files, &mut pending_suspension)) => {
480 match result {
481 Ok(Ok(outcome)) => outcome,
482 Ok(Err(error)) => RunOutcome::FailedWithReason {
483 code: if error.downcast_ref::<AccountingOverflow>().is_some() {
484 FailureReason::AccountingError
485 } else {
486 FailureReason::ToolError
487 },
488 error: error.to_string(),
489 },
490 Err(_) => RunOutcome::LimitExceeded { code: LimitReason::Duration },
491 }
492 }
493 };
494 self.store
495 .finish_run_with_pending_suspension(&run.id, outcome, pending_suspension)
496 .await
497 .with_context(|| format!("could not finalize run {}", run.id))?;
498 self.store
499 .get_run(&run.id)
500 .await?
501 .context("run disappeared")
502 }
503
504 fn remaining_time(&self, run: &AgentRun, resource: &LoadedAgentResource) -> Result<Duration> {
505 let budget = i64::try_from(resource.definition.agent.execution.timeout_seconds)?
506 .checked_mul(1000)
507 .context("timeout is too large")?;
508 let deadline = run
509 .created_at
510 .checked_add(budget)
511 .and_then(|value| value.checked_add(run.suspended_ms))
512 .context("timeout is too large")?;
513 let deadline = self
514 .execution
515 .as_ref()
516 .map_or(deadline, |context| deadline.min(context.deadline));
517 let remaining = deadline.saturating_sub(chrono::Utc::now().timestamp_millis());
518 ensure!(remaining > 0, "original run deadline has expired");
519 Ok(Duration::from_millis(remaining as u64))
520 }
521
522 fn declarations(&self, resource: &LoadedAgentResource) -> Result<Vec<ModelTool>> {
523 self.declarations_with(resource, &ToolRegistry::new())
524 }
525
526 fn declarations_with(
527 &self,
528 resource: &LoadedAgentResource,
529 external: &ToolRegistry,
530 ) -> Result<Vec<ModelTool>> {
531 let agent = &resource.definition.agent;
532 let mut declarations = Vec::new();
533 for name in &agent.tools {
534 let tool = self
535 .tools
536 .find(name)
537 .or_else(|| external.find(name))
538 .context("unsupported runtime tool declaration")?;
539 if tool.runtime_dispatch() == ToolRuntimeDispatch::AgentDelegation {
540 ensure!(
541 !agent.delegation.allow.is_empty()
542 && agent
543 .delegation
544 .allow
545 .iter()
546 .all(|name| self.resources.contains_key(name)),
547 "delegation targets unavailable"
548 );
549 }
550 let dispatch = tool.runtime_dispatch();
551 if !matches!(dispatch, ToolRuntimeDispatch::RunControl(_)) {
552 let capabilities = tool
553 .capabilities()
554 .context("tool capability metadata is unavailable")?;
555 ensure!(
556 self.policy.authorize(&agent.permissions, &capabilities)
557 != Authorization::Denied,
558 "tool capability is not permitted by agent and host policy"
559 );
560 }
561 let mut parameters = tool.parameters_schema();
562 if dispatch == ToolRuntimeDispatch::AgentDelegation {
563 parameters["properties"]["agent"]["enum"] = json!(agent.delegation.allow);
564 }
565 declarations.push(ModelTool {
566 name: name.clone(),
567 description: tool.description().into(),
568 parameters,
569 });
570 }
571 Ok(declarations)
572 }
573
574 #[allow(clippy::too_many_arguments)]
575 async fn execute(
576 &self,
577 run: &AgentRun,
578 resource: &LoadedAgentResource,
579 input: &str,
580 restored: Option<RestoredRequest>,
581 start_turn: u32,
582 files: &files::RunFiles,
583 pending_suspension: &mut Option<Value>,
584 ) -> Result<RunOutcome> {
585 let id = &run.id;
586 let agent = &resource.definition.agent;
587 ensure!(
588 agent.execution.max_turns > 0 && agent.execution.timeout_seconds > 0,
589 "invalid execution limits"
590 );
591 let (external, mut sessions) = self.external_tools(id, resource).await?;
592 let result = async {
593 let declarations = self.declarations_with(resource, &external)?;
594 let context = ToolContext::new(self.config.clone());
595 if restored.is_none() {
596 self.prepare_selected_tools(id, resource, &external, &context)
597 .await?;
598 }
599 let restored_indexes = restored.as_ref().and_then(|state| state.logical_indexes.clone());
600 let restored_source_count = restored.as_ref().and_then(|state| state.source_message_count);
601 let restored_exchange_count = restored.as_ref().and_then(|state| state.source_exchange_count);
602 let initial = self.context_builder.project(
603 &resource.definition.prompt.system,
604 input,
605 declarations,
606 restored.map(|state| state.request),
607 )?;
608 let bounded = agent.execution.max_context_bytes;
609 let mut source = initial.request;
610 if bounded.is_some()
611 && source.messages.get(1).is_some_and(|message| matches!(message, ModelMessage::System { .. }))
612 {
613 source.messages.remove(1);
614 }
615 let mut logical_indexes = restored_indexes.unwrap_or_else(|| (0..source.messages.len() as u64).collect());
616 let mut source_message_count = restored_source_count.unwrap_or(source.messages.len() as u64);
617 let mut source_exchange_count = restored_exchange_count.unwrap_or_else(|| context::exchange_count(&source));
618 let initial_metadata = if let Some(max_bytes) = bounded {
619 let working = match self.store.working_state(id).await {
620 Ok(working) => working,
621 Err(_) => return Ok(RunOutcome::FailedWithReason {
622 code: FailureReason::StorageError,
623 error: "working state is unavailable".into(),
624 }),
625 };
626 if working.status != WorkingStateStatus::Current {
627 return Ok(RunOutcome::FailedWithReason { code: FailureReason::StorageError, error: "working state is not current".into() });
628 }
629 match context::bounded_project(&source, max_bytes, &working, &logical_indexes, source_message_count, source_exchange_count) {
630 Ok(projected) => projected.metadata,
631 Err(error) if error.to_string() == "context_bytes" => return Ok(RunOutcome::LimitExceeded { code: LimitReason::ContextBytes }),
632 Err(error) => return Err(error),
633 }
634 } else { self.context_builder.describe(&source) };
635 self.store
636 .append_event(
637 id,
638 "context.resolved",
639 &json!({
640 "workspace_root": self.root, "agent_version": resource.version,
641 "tools": agent.tools, "retrieval": "keyword",
642 "projection": initial_metadata,
643 "tool_binding_contract": tool_binding::CATALOG_CONTRACT_VERSION,
644 "tool_bindings": self.selected_binding_metadata_with(resource, Some(&external))?,
645 "host_policy": {"allow":self.policy.allow, "require_approval":self.policy.require_approval}
646 }),
647 )
648 .await?;
649 let mut turn = start_turn;
650 while turn < agent.execution.max_turns {
651 let (mut request, projection) = if let Some(max_bytes) = bounded {
652 let working = match self.store.working_state(id).await {
653 Ok(working) => working,
654 Err(_) => return Ok(RunOutcome::FailedWithReason {
655 code: FailureReason::StorageError,
656 error: "working state is unavailable".into(),
657 }),
658 };
659 if working.status != WorkingStateStatus::Current {
660 return Ok(RunOutcome::FailedWithReason { code: FailureReason::StorageError, error: "working state is not current".into() });
661 }
662 let projected = match context::bounded_project(&source, max_bytes, &working, &logical_indexes, source_message_count, source_exchange_count) {
663 Ok(projected) => projected,
664 Err(error) if error.to_string() == "context_bytes" => return Ok(RunOutcome::LimitExceeded { code: LimitReason::ContextBytes }),
665 Err(error) => return Err(error),
666 };
667 source = projected.retained_source.context("bounded projection omitted retained source")?;
668 logical_indexes = projected.retained_indexes.context("bounded projection omitted logical indexes")?;
669 self.store.append_event(id, "context.projected", &serde_json::to_value(&projected.metadata)?).await?;
670 (projected.request, projected.metadata)
671 } else {
672 (source.clone(), self.context_builder.describe(&source))
673 };
674 self.checkpoint(
675 id,
676 resource,
677 checkpoint::CheckpointContext {
678 request: &request,
679 projection: &projection,
680 logical: bounded.map(|_| (logical_indexes.as_slice(), source_message_count, source_exchange_count)),
681 },
682 turn,
683 (turn == start_turn).then_some(run),
684 )
685 .await?;
686 let recorded = match self
687 .models
688 .generate_recorded_with_budget(&self.store, id, &agent.model, &request)
689 .await
690 {
691 Ok(response) => response,
692 Err(error) => {
693 return Ok(RunOutcome::FailedWithReason {
694 code: if error.downcast_ref::<AccountingOverflow>().is_some() {
695 FailureReason::AccountingError
696 } else {
697 FailureReason::ModelError
698 },
699 error: if error.downcast_ref::<AccountingOverflow>().is_some() {
700 "run accounting overflow".into()
701 } else {
702 "model request failed".into()
703 },
704 });
705 }
706 };
707 turn = turn.saturating_add(recorded.attempts);
708 if let Some(code) = recorded.limit {
709 return Ok(RunOutcome::LimitExceeded { code });
710 }
711 let response = recorded.response;
712 match response.finish_reason {
713 FinishReason::Completed => {
714 if response.text.len() <= 64 * 1024 {
715 return Ok(RunOutcome::Completed(response.text));
716 }
717 let artifact = files.write_artifact("output.txt", response.text.as_bytes())?;
718 self.store
719 .record_artifact(
720 id,
721 &artifact.relative_path,
722 &artifact.sha256,
723 artifact.size,
724 )
725 .await?;
726 return Ok(RunOutcome::Completed(format!(
727 "Output saved to {} ({} bytes; sha256 {})",
728 artifact.relative_path, artifact.size, artifact.sha256
729 )));
730 }
731 FinishReason::Length => return Ok(RunOutcome::FailedWithReason {
732 code: FailureReason::ModelOutputTruncated,
733 error: "model output limit reached".into(),
734 }),
735 FinishReason::Refusal => return Ok(RunOutcome::FailedWithReason {
736 code: FailureReason::ModelRefusal,
737 error: "model refused the request".into(),
738 }),
739 FinishReason::ContentFilter => return Ok(RunOutcome::FailedWithReason {
740 code: FailureReason::ContentFiltered,
741 error: "model response was filtered".into(),
742 }),
743 FinishReason::ToolCalls => {}
744 }
745 let controls: Vec<_> = response
746 .tool_calls
747 .iter()
748 .filter_map(|call| {
749 self.tools
750 .find(&call.name)
751 .or_else(|| external.find(&call.name))
752 .and_then(|tool| match tool.runtime_dispatch() {
753 ToolRuntimeDispatch::RunControl(kind) => Some((kind, call)),
754 _ => None,
755 })
756 })
757 .collect();
758 if !controls.is_empty() {
759 if controls.len() != 1 || response.tool_calls.len() != 1 {
760 return Ok(RunOutcome::FailedWithReason {
761 code: FailureReason::InvalidModelResponse,
762 error: "invalid run control response".into(),
763 });
764 }
765 let (kind, call) = controls[0];
766 let outcome = control::outcome(kind, call.arguments.clone()).unwrap_or(
767 RunOutcome::FailedWithReason {
768 code: FailureReason::InvalidModelResponse,
769 error: "invalid run control response".into(),
770 },
771 );
772 let pending = serde_json::to_value(response.message())?;
773 if serde_json::to_vec(&pending)?.len() > 16 * 1024 * 1024 {
774 return Ok(RunOutcome::LimitExceeded {
775 code: LimitReason::CheckpointSize,
776 });
777 }
778 if matches!(outcome, RunOutcome::Blocked { .. } | RunOutcome::NeedsUserInput { .. }) {
779 *pending_suspension = Some(pending);
780 }
781 return Ok(outcome);
782 }
783 if turn >= agent.execution.max_turns {
785 return Ok(RunOutcome::LimitExceeded {
786 code: LimitReason::ModelTurns,
787 });
788 }
789 if let BudgetDecision::Exceeded(code) =
790 self.store.check_model_turn_budget(id).await?
791 {
792 return Ok(RunOutcome::LimitExceeded { code });
793 }
794 if response.tool_calls.len() > MAX_TOOL_CALLS_PER_TURN {
795 return Ok(RunOutcome::LimitExceeded {
796 code: LimitReason::ToolCallsPerTurn,
797 });
798 }
799 if matches!(
800 self.store
801 .check_tool_call_budget(id, response.tool_calls.len())
802 .await?,
803 BudgetDecision::Exceeded(_)
804 ) {
805 return Ok(RunOutcome::LimitExceeded {
806 code: LimitReason::ToolCalls,
807 });
808 }
809 let assistant = response.message();
810 request.messages.push(assistant.clone());
811 source.messages.push(assistant);
812 logical_indexes.push(source_message_count);
813 source_message_count += 1;
814 source_exchange_count += 1;
815 for call in response.tool_calls {
816 ensure!(
817 matches!(
818 self.store.check_model_turn_budget(id).await?,
819 BudgetDecision::Allowed
820 ),
821 "shared model turn budget exhausted"
822 );
823 self.store
824 .request_tool(id, &call.id, &call.name, &call.arguments)
825 .await?;
826 let tool = self.tools.find(&call.name).or_else(|| external.find(&call.name)).context("tool unavailable")?;
827 let dispatch = tool.runtime_dispatch();
828 let authorization = tool
829 .capabilities()
830 .map(|caps| self.policy.authorize(&agent.permissions, &caps))
831 .unwrap_or(Authorization::Denied);
832 let valid = match dispatch {
833 ToolRuntimeDispatch::AgentDelegation => {
834 self.validate_delegation(resource, &call.arguments)
835 }
836 ToolRuntimeDispatch::Direct => tool.validate_arguments(&call.arguments),
837 ToolRuntimeDispatch::RunControl(_) => {
838 anyhow::bail!("run control reached ordinary dispatch")
839 }
840 };
841 if !agent.tools.contains(&call.name)
842 || authorization == Authorization::Denied
843 || valid.is_err()
844 {
845 self.store
846 .finish_tool(
847 id,
848 &call.id,
849 ToolOutcome::Denied(
850 "tool permission or argument validation rejected".into(),
851 ),
852 )
853 .await?;
854 anyhow::bail!("tool permission or argument validation rejected");
855 }
856 let approval_arguments = tool.approval_arguments(&call.arguments)?;
857 self.approve_invocation(id, &call.id, &call.name, &approval_arguments, authorization).await?;
858 self.store.start_tool(id, &call.id).await?;
859 let result = match dispatch {
860 ToolRuntimeDispatch::AgentDelegation => {
861 self.invoke(id, &call.id, resource, call.arguments).await
862 }
863 ToolRuntimeDispatch::Direct => tool.execute(call.arguments, &context).await,
864 ToolRuntimeDispatch::RunControl(_) => unreachable!(),
865 };
866 match result {
867 Ok(result) => {
868 let content = serde_json::to_string(&result)?;
869 if content.len() > MAX_TOOL_RESULT_BYTES {
870 self.store
871 .finish_tool(
872 id,
873 &call.id,
874 ToolOutcome::Failed("tool result exceeds 1 MiB".into()),
875 )
876 .await?;
877 anyhow::bail!("tool result exceeds 1 MiB");
878 }
879 self.store
880 .finish_tool(id, &call.id, ToolOutcome::Completed(result))
881 .await?;
882 let message = ModelMessage::Tool {
883 call_id: call.id,
884 content,
885 };
886 request.messages.push(message.clone());
887 source.messages.push(message);
888 logical_indexes.push(source_message_count);
889 source_message_count += 1;
890 }
891 Err(error) => {
892 if matches!(dispatch, ToolRuntimeDispatch::Direct) {
893 if let Some(classified) = classified_tool_error(&error) {
894 match classified.class() {
895 ToolExecutionErrorClass::RecoverableError => {
896 let (observation, content) =
897 recoverable_observation(classified)?;
898 if content.len() > MAX_TOOL_RESULT_BYTES {
899 self.store
900 .finish_tool(
901 id,
902 &call.id,
903 ToolOutcome::Failed(
904 "tool result exceeds 1 MiB".into(),
905 ),
906 )
907 .await?;
908 anyhow::bail!("tool result exceeds 1 MiB");
909 }
910 let key = repetition_key(
911 tool,
912 approval_arguments,
913 classified.code(),
914 )?;
915 let count = self
916 .store
917 .finish_tool_with_count(
918 id,
919 &call.id,
920 ToolOutcome::Recoverable {
921 code: classified.code().into(),
922 message: classified.message().into(),
923 observation,
924 observation_content: content.clone(),
925 repetition_key: key.clone(),
926 },
927 )
928 .await?
929 .context("recoverable repetition count missing")?;
930 if count >= 3 {
931 return Ok(RunOutcome::FailedWithDetail {
932 code: FailureReason::ToolLoopDetected,
933 error: "repeated recoverable tool error".into(),
934 detail: json!({
935 "repetition_key": key,
936 "consecutive_count": count,
937 "tool": call.name,
938 "error_code": classified.code(),
939 }),
940 });
941 }
942 let message = ModelMessage::Tool {
943 call_id: call.id,
944 content,
945 };
946 request.messages.push(message.clone());
947 source.messages.push(message);
948 logical_indexes.push(source_message_count);
949 source_message_count += 1;
950 continue;
951 }
952 ToolExecutionErrorClass::TerminalError => {
953 self.store
954 .finish_tool(
955 id,
956 &call.id,
957 ToolOutcome::Terminal {
958 code: classified.code().into(),
959 message: classified.message().into(),
960 },
961 )
962 .await?;
963 return Ok(RunOutcome::FailedWithDetail {
964 code: FailureReason::ToolError,
965 error: classified.message().into(),
966 detail: json!({"code": classified.code(), "message": classified.message()}),
967 });
968 }
969 ToolExecutionErrorClass::UncertainSideEffect => {
970 self.store
971 .finish_tool(
972 id,
973 &call.id,
974 ToolOutcome::UncertainSideEffect {
975 code: classified.code().into(),
976 message: classified.message().into(),
977 },
978 )
979 .await?;
980 return Ok(RunOutcome::FailedWithDetail {
981 code: FailureReason::ToolError,
982 error: classified.message().into(),
983 detail: json!({"code": classified.code(), "message": classified.message()}),
984 });
985 }
986 }
987 }
988 }
989 self.store
992 .finish_tool(
993 id,
994 &call.id,
995 ToolOutcome::Failed("context tool execution failed".into()),
996 )
997 .await?;
998 anyhow::bail!("context tool execution failed");
999 }
1000 }
1001 }
1002 }
1003 Ok(RunOutcome::LimitExceeded {
1004 code: LimitReason::ModelTurns,
1005 })
1006 }
1007 .await;
1008 for session in &mut sessions {
1009 session.close().await;
1010 }
1011 result
1012 }
1013
1014 async fn prepare_selected_tools(
1015 &self,
1016 id: &str,
1017 resource: &LoadedAgentResource,
1018 external: &ToolRegistry,
1019 context: &ToolContext,
1020 ) -> Result<()> {
1021 for name in &resource.definition.agent.tools {
1022 let tool = self
1023 .tools
1024 .find(name)
1025 .or_else(|| external.find(name))
1026 .context("tool unavailable")?;
1027 let Some(preparation) = tool.preparation() else {
1028 continue;
1029 };
1030 let call_id = format!("prepare:{name}");
1031 self.store
1032 .request_tool(id, &call_id, &preparation.name, &preparation.arguments)
1033 .await?;
1034 let authorization = self.policy.authorize(
1035 &resource.definition.agent.permissions,
1036 &preparation.capabilities,
1037 );
1038 self.approve_invocation(
1039 id,
1040 &call_id,
1041 &preparation.name,
1042 &preparation.arguments,
1043 authorization,
1044 )
1045 .await?;
1046 self.store.start_tool(id, &call_id).await?;
1047 match tool.prepare(context).await {
1048 Ok(()) => {
1049 self.store
1050 .finish_tool(
1051 id,
1052 &call_id,
1053 ToolOutcome::Completed(json!({"prepared":true})),
1054 )
1055 .await?;
1056 }
1057 Err(_) => {
1058 self.store
1059 .finish_tool(
1060 id,
1061 &call_id,
1062 ToolOutcome::Failed("tool preparation failed".into()),
1063 )
1064 .await?;
1065 anyhow::bail!("tool preparation failed");
1066 }
1067 }
1068 }
1069 Ok(())
1070 }
1071}
1072
1073async fn cancelled(receiver: &mut watch::Receiver<bool>) {
1074 loop {
1075 if *receiver.borrow_and_update() {
1076 return;
1077 }
1078 if receiver.changed().await.is_err() {
1079 std::future::pending::<()>().await;
1080 }
1081 }
1082}