Skip to main content

context_harness/agent_runtime/
mod.rs

1//! Bounded direct execution of static agent resources. Runtime persistence is
2//! intentionally writable; model-requested retrieval uses read-only connections.
3mod 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
128/// Workspace binding for the existing cwd-scoped CLI. Registry-based workspace
129/// selection is not added here. Canonical roots retain identity across symlinks.
130pub 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
138/// Resolve the exact advertised schemas and non-secret binding metadata used by
139/// accepted tasks without opening the database or binding/executing a tool.
140pub(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    /// Initialize runtime history and bind all context reads to this config/DB.
216    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    /// A trusted host may supply an explicit policy and approval UI. Agent TOML
270    /// cannot change this ceiling. By default privileged calls need approval,
271    /// and the library denies approvals until a handler is installed.
272    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    /// Resolve and bind standalone tool resources through the trusted core catalog.
283    /// Host authority is the existing workspace read boundary; resources only narrow it.
284    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    /// Bind resources using a catalog and authority supplied by a trusted embedding host.
323    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    /// Return persisted terminal state even for expected execution failures, so
375    /// callers always receive a run ID. Storage errors remain ordinary errors.
376    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    /// Drive a root run whose creation was atomically linked by the task store.
401    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                // Do not execute tools if the run cannot consume their results.
784                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                            // Tool errors may contain indexed data. Persist a category,
990                            // not arbitrary error strings from tool implementations.
991                            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}