Skip to main content

orchestral_runtime/generic_agent/
mod.rs

1//! Provider-neutral Generic Agent implementation.
2//!
3//! Tools are optional and can only enter through the Host-owned guarded Tool
4//! runtime. A model tool call never carries authority by itself.
5
6use std::collections::{BTreeMap, BTreeSet, VecDeque};
7use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
8use std::sync::{Arc, Mutex, MutexGuard};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use async_trait::async_trait;
12use futures_util::{stream, StreamExt};
13use orchestral_core::agent_protocol::{
14    spi::{
15        AgentProvider, AgentProviderStream, AgentRecovery, AgentRecoveryRequest, AgentStart,
16        AgentStartError,
17    },
18    wire::{
19        AgentAdmission, AgentCapabilities, AgentCommand, AgentCommandEnvelope, AgentDelivery,
20        AgentDescriptor, AgentDescriptorEnvelope, AgentEvent, AgentEventDraft, AgentEventId,
21        AgentExecutionRef, AgentFailure, AgentId, AgentProtocolError, AgentProtocolErrorCode,
22        AgentProviderId, AgentProviderStreamItem, AgentRejection, AgentRejectionCode,
23        AgentStartRequest, AgentTelemetry, AgentTelemetryEnvelope, ApprovalDecision,
24        ArtifactRefWithDigest, BindingRequirement, CancelSupport, CommandId, Content, ContentBody,
25        ControlCapabilities, DeliveryId, Digest, EffectMediation, IncompleteReason, MoneyAmount,
26        OutputId, PartialDelivery, PartialDeliveryId, PendingRequest, PendingRequestKind,
27        PendingRequestPayload, Provenance, ProviderCommandDisposition, ProviderCommandOutcome,
28        RequestId, RequestResolution, ResourceBindingMode, ResourceBindingSkip,
29        ResourceBindingSkipCode, ResourceCapability, ResourceKind, RunId, RunLimitKind,
30        TelemetryId, ToolActivityErrorDetail, ToolActivityEvidence, ToolActivityId,
31        ToolActivityState, UsageReport,
32    },
33    AGENT_PROTOCOL_V1,
34};
35use orchestral_core::agent_session::{
36    AgentSessionError, AgentSessionEvent, AgentSessionEventDraft, AgentSessionEventId,
37    AgentSessionJournalStore, AgentSessionRecord, InMemoryAgentSessionJournalStore,
38};
39use orchestral_core::executor::{ExecutionProgressEvent, ExecutionProgressReporter};
40use orchestral_core::model_protocol::{
41    ModelBackend, ModelContent, ModelError, ModelErrorCode, ModelEvent, ModelFinishReason,
42    ModelMessage, ModelRequest, ModelRequestId, ModelRole, ModelToolCallId, ModelToolDefinition,
43    ModelUsage,
44};
45pub use orchestral_core::model_retry::{ContextRecoveryPolicy, ModelRetryPolicy};
46use orchestral_core::project_instructions::ProjectInstruction;
47use orchestral_core::skill_protocol::SkillLoad;
48use orchestral_core::tool_protocol::{
49    ApprovalBinding, ApprovalCapability, RunToolGrant, ToolCallId, ToolInvocation, ToolOutcome,
50    ToolOutput,
51};
52use orchestral_core::types::{Plan, WorkflowId};
53use serde::Deserialize;
54use tokio::sync::{broadcast, oneshot, watch};
55use tokio_util::sync::CancellationToken;
56
57use crate::approval_bridge::AgentApprovalBridge;
58use crate::generic_agent_checkpoint::{
59    AppendGenericCheckpointOutcome, CreateGenericRunOutcome, GenericAgentCheckpointStore,
60    GenericAgentRunRegistration, GenericCheckpointDraft, GenericCheckpointError,
61    GenericCheckpointEvent, GenericCheckpointEventId, GenericCheckpointPhase, GenericLoopBoundary,
62    GenericModelContextTrace, GenericModelObservation, GenericObservedToolCall,
63    InMemoryGenericAgentCheckpointStore, StoredGenericAgentRun,
64};
65use crate::skill::{LoadedSkillSet, SkillLoadOutcome, SkillRuntime};
66use crate::tool_runtime::{AgentToolRuntime, GuardedToolResult, ToolRuntimeError};
67use crate::workflow_strategy::{WorkflowExecutionRequest, WorkflowExecutionStrategy};
68use crate::{
69    AgentSessionCompactor, AgentSessionContextEngine, AgentSessionSummarizer, JsonSizeTokenMeter,
70    ModelTokenMeter, ModelTokenMeterDescriptor, SessionCompactionPolicy, SessionContextError,
71    SessionContextProjection, SessionContextRequest, SessionSummarizerDescriptor,
72};
73
74const WORKFLOW_TOOL_NAME: &str = "orchestral_workflow";
75const SKILL_READ_TOOL_NAME: &str = "skill_read";
76const REQUEST_INPUT_TOOL_NAME: &str = "orchestral_request_input";
77const RUN_STOP_RUNNING: u8 = 0;
78const RUN_STOP_HOST_CANCEL: u8 = 1;
79const RUN_STOP_DEADLINE: u8 = 2;
80const RUN_STOP_COMPLETING: u8 = 3;
81const TOKENS_PER_MILLION: u128 = 1_000_000;
82
83/// Host-owned, provider-neutral token pricing used to enforce a Run cost
84/// ceiling before a model request is dispatched. Providers with cached,
85/// tiered, or otherwise non-linear pricing must leave this unset until an
86/// equivalent conservative policy is available.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ModelCostPolicy {
89    pub currency: String,
90    pub input_microunits_per_million_tokens: u64,
91    pub output_microunits_per_million_tokens: u64,
92}
93
94impl ModelCostPolicy {
95    pub fn new(
96        currency: impl Into<String>,
97        input_microunits_per_million_tokens: u64,
98        output_microunits_per_million_tokens: u64,
99    ) -> Result<Self, AgentProtocolError> {
100        let policy = Self {
101            currency: currency.into(),
102            input_microunits_per_million_tokens,
103            output_microunits_per_million_tokens,
104        };
105        policy.validate()?;
106        Ok(policy)
107    }
108
109    fn validate(&self) -> Result<(), AgentProtocolError> {
110        if self.currency.len() != 3
111            || !self.currency.bytes().all(|byte| byte.is_ascii_uppercase())
112            || (self.input_microunits_per_million_tokens == 0
113                && self.output_microunits_per_million_tokens == 0)
114        {
115            return Err(AgentProtocolError::new(
116                AgentProtocolErrorCode::InvalidSpec,
117                "model cost policy requires an uppercase currency and at least one positive rate",
118            ));
119        }
120        Ok(())
121    }
122
123    pub fn quote(&self, input_tokens: u64, output_tokens: u64) -> MoneyAmount {
124        let input = u128::from(input_tokens)
125            .saturating_mul(u128::from(self.input_microunits_per_million_tokens));
126        let output = u128::from(output_tokens)
127            .saturating_mul(u128::from(self.output_microunits_per_million_tokens));
128        let microunits = input
129            .saturating_add(output)
130            .div_ceil(TOKENS_PER_MILLION)
131            .min(u128::from(u64::MAX)) as u64;
132        MoneyAmount {
133            currency: self.currency.clone(),
134            microunits,
135        }
136    }
137
138    fn max_output_tokens_within(
139        &self,
140        input_tokens: u64,
141        output_tokens: u64,
142        ceiling: &MoneyAmount,
143    ) -> Option<u64> {
144        if ceiling.currency != self.currency
145            || self.quote(input_tokens, 0).microunits > ceiling.microunits
146        {
147            return None;
148        }
149        if self.output_microunits_per_million_tokens == 0 {
150            return Some(output_tokens);
151        }
152        let mut low = 0_u64;
153        let mut high = output_tokens;
154        while low < high {
155            let candidate = low.saturating_add(high).saturating_add(1) / 2;
156            if self.quote(input_tokens, candidate).microunits <= ceiling.microunits {
157                low = candidate;
158            } else {
159                high = candidate.saturating_sub(1);
160            }
161        }
162        Some(low)
163    }
164}
165
166#[derive(Debug, Clone)]
167pub struct GenericAgentConfig {
168    pub provider_id: AgentProviderId,
169    pub agent_id: AgentId,
170    pub system_prompt: String,
171    /// Advertise and expose input requests only when the Host can answer them.
172    pub input_requests_enabled: bool,
173    /// Host-lifetime instruction snapshot, included in the recovery identity.
174    pub project_instructions: Vec<ProjectInstruction>,
175    /// Retries before model content or Finish; never replays tool execution.
176    pub model_retry: ModelRetryPolicy,
177    pub context_recovery: ContextRecoveryPolicy,
178    pub stream_buffer: usize,
179    pub continuation: ContinuationPolicy,
180    pub history_limit: usize,
181    pub max_context_tokens: u64,
182    pub reserved_output_tokens: u64,
183    /// Optional minimum response room before active context is compacted.
184    /// The preferred response budget remains reserved_output_tokens. None
185    /// preserves the fixed-reservation behavior.
186    pub minimum_output_reserve_tokens: Option<u64>,
187    pub model_cost_policy: Option<ModelCostPolicy>,
188}
189
190/// Host ceiling for one continuous Agent turn.
191///
192/// An absent ceiling means that normal progress is not stopped by an arbitrary
193/// number of model or Tool exchanges. Per-Run limits from Agent Protocol are
194/// intersected with these Host ceilings when either side explicitly supplies
195/// one. Deadline, token, cost, cancellation, and terminal-state checks remain
196/// independent continuation boundaries.
197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
198pub struct ContinuationPolicy {
199    pub max_model_steps: Option<u64>,
200    pub max_tool_calls: Option<u64>,
201}
202
203impl ContinuationPolicy {
204    pub fn effective_model_steps(self, requested: Option<u64>) -> Option<u64> {
205        intersect_limit(requested, self.max_model_steps)
206    }
207
208    pub fn effective_tool_calls(self, requested: Option<u64>) -> Option<u64> {
209        intersect_limit(requested, self.max_tool_calls)
210    }
211
212    fn validate(self) -> Result<(), AgentProtocolError> {
213        if self.max_model_steps == Some(0) || self.max_tool_calls == Some(0) {
214            return Err(AgentProtocolError::new(
215                AgentProtocolErrorCode::InvalidSpec,
216                "configured continuation ceilings must be positive when present",
217            ));
218        }
219        Ok(())
220    }
221}
222
223fn intersect_limit(requested: Option<u64>, host_ceiling: Option<u64>) -> Option<u64> {
224    match (requested, host_ceiling) {
225        (Some(requested), Some(host_ceiling)) => Some(requested.min(host_ceiling)),
226        (Some(limit), None) | (None, Some(limit)) => Some(limit),
227        (None, None) => None,
228    }
229}
230
231impl GenericAgentConfig {
232    pub fn new(provider_id: impl Into<String>, agent_id: impl Into<String>) -> Self {
233        Self {
234            provider_id: AgentProviderId::new(provider_id),
235            agent_id: AgentId::new(agent_id),
236            system_prompt: concat!(
237                "You are Orchestral, an agent running in a local application. ",
238                "You and the user share one or more Host-provided workspaces. Work toward the user's ",
239                "requested outcome using the supplied context and Tools. Tool definitions and ",
240                "Host policy are authoritative capability boundaries. Inspect available ",
241                "evidence before making claims, take relevant reversible actions when the ",
242                "request is clear, and ask only when a material choice or required fact cannot ",
243                "be derived. Treat explicit ordering, preconditions, and requested final states ",
244                "as acceptance constraints: establish them before dependent work and verify ",
245                "them before delivery. Do not broaden completed work with unrequested ",
246                "integration, publication, cleanup, or reversal. ",
247                "Batch independent observations whose arguments are supported by current context ",
248                "in one tool-call response. This can include inspections and an already-established ",
249                "validation command when neither needs the other's result. Wait when a result can ",
250                "change another call's arguments, necessity, or safety; keep edits and their ",
251                "verification ordered. ",
252                "Prefer a dedicated Tool over a shell equivalent when one is available. For ",
253                "multiple workspaces, use the exact Host-provided workspace selector on file ",
254                "Tools and the matching workdir on exec_command; do not fall back to grep, cat, ",
255                "or shell-based edits merely because the target is not in the primary workspace. ",
256                "For workspace text changes, prefer file_edit for exact, unique text replacements. ",
257                "Group currently known non-overlapping changes to one file in a single edits array; ",
258                "all old_text values match the original file. Use apply_patch for structured ",
259                "changes across files. ",
260                "Use file_write to create or intentionally replace a complete file. Inspect ",
261                "existing content before changing it and run relevant ",
262                "verification. Keep user-facing responses concise unless the user requests a ",
263                "detailed explanation. For completed work, briefly state the outcome, verification ",
264                "results, and any remaining gaps. Include changed code or raw Tool logs only when ",
265                "requested or needed to explain an unresolved issue. Avoid repeating explanations ",
266                "or checks that add no new evidence. Permission is owned by the Host, not inferred ",
267                "by you. Treat every Tool ",
268                "failure as an observation to correct or safely work around; report completion ",
269                "only from successful evidence."
270            )
271            .to_owned(),
272            stream_buffer: 128,
273            input_requests_enabled: true,
274            project_instructions: Vec::new(),
275            model_retry: ModelRetryPolicy::default(),
276            context_recovery: ContextRecoveryPolicy::default(),
277            continuation: ContinuationPolicy::default(),
278            history_limit: 128,
279            max_context_tokens: 128 * 1024,
280            reserved_output_tokens: 4 * 1024,
281            minimum_output_reserve_tokens: None,
282            model_cost_policy: None,
283        }
284    }
285}
286
287#[derive(Clone)]
288pub struct InternalGenericAgentProvider {
289    inner: Arc<GenericInner>,
290}
291
292struct GenericInner {
293    backend: Arc<dyn ModelBackend>,
294    descriptor: AgentDescriptorEnvelope,
295    config: GenericAgentConfig,
296    tools: Option<GenericTools>,
297    skills: Option<Arc<SkillRuntime>>,
298    session_journal: Arc<dyn AgentSessionJournalStore>,
299    context_engine: AgentSessionContextEngine,
300    session_compactor: Option<Arc<AgentSessionCompactor>>,
301    checkpoint_store: Arc<dyn GenericAgentCheckpointStore>,
302    config_digest: Digest,
303    state: Mutex<GenericState>,
304}
305
306struct GenericTools {
307    runtime: Arc<dyn AgentToolRuntime>,
308    runtime_contract_digest: Digest,
309    run_grant: RunToolGrant,
310    model_definitions: Vec<ModelToolDefinition>,
311    workflow: Option<Arc<WorkflowExecutionStrategy>>,
312    approval_bridge: Option<Arc<dyn AgentApprovalBridge>>,
313}
314
315#[derive(Default)]
316struct GenericState {
317    runs: BTreeMap<RunId, GenericRun>,
318    sessions: BTreeMap<orchestral_core::agent_protocol::wire::AgentSessionId, GenericSession>,
319}
320
321#[derive(Default)]
322struct GenericSession {
323    active_run: Option<RunId>,
324}
325
326struct GenericRun {
327    request: AgentStartRequest,
328    execution: AgentExecutionRef,
329    admission: AgentAdmission,
330    durable_events: Vec<AgentEventDraft>,
331    sender: broadcast::Sender<Result<AgentProviderStreamItem, AgentProtocolError>>,
332    terminal: bool,
333    cancellation: CancellationToken,
334    stop_cause: Arc<AtomicU8>,
335    cancel_command: Option<(CommandId, String)>,
336    commands: BTreeMap<CommandId, StoredCommand>,
337    queued_steers: VecDeque<QueuedSteer>,
338    steer_signal: watch::Sender<u64>,
339    pending_inputs: BTreeMap<RequestId, PendingInput>,
340    pending_approvals: BTreeMap<RequestId, PendingApproval>,
341    checkpoint_seq: u64,
342}
343
344struct QueuedSteer {
345    command_id: CommandId,
346    content: Vec<Content>,
347    message: ModelMessage,
348    deferred: bool,
349}
350
351struct PendingInput {
352    responder: Option<oneshot::Sender<InputResponse>>,
353}
354
355#[derive(Clone)]
356struct InputResponse {
357    command_id: CommandId,
358    resolution: RequestResolution,
359}
360
361struct PendingApproval {
362    binding: ApprovalBinding,
363    responder: Option<oneshot::Sender<ApprovalResponse>>,
364}
365
366#[derive(Clone)]
367struct ApprovalResponse {
368    command_id: CommandId,
369    resolution: RequestResolution,
370    capability: Option<ApprovalCapability>,
371}
372
373struct RecoveredResolution {
374    command_id: CommandId,
375    resolution: RequestResolution,
376    capability: Option<ApprovalCapability>,
377}
378
379struct RecoveredApprovalWaiter {
380    request_id: RequestId,
381    binding: ApprovalBinding,
382    replayed_outcome: Option<ToolOutcome>,
383    responder: Option<oneshot::Sender<ApprovalResponse>>,
384    response: Option<oneshot::Receiver<ApprovalResponse>>,
385    bridge: Arc<dyn AgentApprovalBridge>,
386}
387
388struct StoredCommand {
389    digest: Digest,
390    outcome: ProviderCommandOutcome,
391}
392
393struct GenericExecutionSeed {
394    run_started: bool,
395    next_model_round: u64,
396    total_usage: ModelUsage,
397    tool_call_count: u64,
398    last_response: String,
399    supporting_event_ids: Vec<AgentEventId>,
400}
401
402// Recovery state is created once per Run and retained behind the provider's
403// Run allocation; keeping the variants explicit is safer than obscuring their
404// durable-boundary fields behind unrelated heap payload types.
405#[allow(clippy::large_enum_variant)]
406enum GenericRecoveryContinuation {
407    ModelLoop {
408        restore_initial_input: bool,
409    },
410    Input {
411        round: u64,
412        request_id: ModelRequestId,
413        request_digest: Digest,
414        observation: GenericModelObservation,
415        call: GenericObservedToolCall,
416        arguments: serde_json::Value,
417        prompt: String,
418        request_open: bool,
419        committed_response: Option<InputResponse>,
420        resolved_response: Option<InputResponse>,
421        response: Option<oneshot::Receiver<InputResponse>>,
422    },
423    Approval {
424        round: u64,
425        request_id: ModelRequestId,
426        request_digest: Digest,
427        observation: GenericModelObservation,
428        call: GenericObservedToolCall,
429        arguments: serde_json::Value,
430        request: PendingRequest,
431        binding: Option<ApprovalBinding>,
432        committed_response: Option<ApprovalResponse>,
433        resolved_response: Option<ApprovalResponse>,
434        response: Option<oneshot::Receiver<ApprovalResponse>>,
435    },
436    Skill {
437        round: u64,
438        request_id: ModelRequestId,
439        request_digest: Digest,
440        observation: GenericModelObservation,
441        call: GenericObservedToolCall,
442        arguments: serde_json::Value,
443        recovered_observation: Option<SkillCallObservation>,
444    },
445    Workflow {
446        round: u64,
447        request_id: ModelRequestId,
448        request_digest: Digest,
449        observation: GenericModelObservation,
450        call: GenericObservedToolCall,
451        arguments: serde_json::Value,
452        recovery_replay: bool,
453    },
454    WorkflowOutput {
455        round: u64,
456        request_id: ModelRequestId,
457        request_digest: Digest,
458        observation: GenericModelObservation,
459        call: GenericObservedToolCall,
460        arguments: serde_json::Value,
461        outcome: WorkflowCallObservation,
462        workflow_event_id: AgentEventId,
463    },
464    Tool {
465        round: u64,
466        request_id: ModelRequestId,
467        request_digest: Digest,
468        observation: GenericModelObservation,
469        call: GenericObservedToolCall,
470        arguments: serde_json::Value,
471    },
472}
473
474impl GenericExecutionSeed {
475    fn fresh() -> Self {
476        Self {
477            run_started: false,
478            next_model_round: 1,
479            total_usage: ModelUsage::default(),
480            tool_call_count: 0,
481            last_response: String::new(),
482            supporting_event_ids: Vec::new(),
483        }
484    }
485}
486
487mod command;
488mod context_anchor;
489mod coordinator;
490mod provider;
491mod provider_spi;
492mod recovery_activate;
493use context_anchor::*;
494mod recovery_approval;
495mod recovery_dispatch;
496mod recovery_entry;
497mod recovery_stage;
498use recovery_activate::*;
499use recovery_approval::*;
500use recovery_dispatch::*;
501use recovery_stage::*;
502mod recovery_loop;
503use recovery_loop::*;
504mod recovery_projection;
505use recovery_projection::*;
506mod context;
507use context::*;
508mod context_recovery;
509mod model_retry;
510mod model_step;
511use model_step::*;
512mod tool_step;
513use tool_step::*;
514mod recovery_resume;
515mod recovery_tool;
516use recovery_resume::*;
517use recovery_tool::*;
518mod control;
519use control::*;
520mod skills;
521use skills::*;
522mod workflow;
523use workflow::*;
524mod state_flow;
525use state_flow::*;
526mod completion;
527use completion::*;
528mod setup;
529use setup::*;
530
531#[cfg(test)]
532mod tests;