Skip to main content

talon/
agent.rs

1//! Seams between the orchestrator and the outside world: launching agent
2//! containers ([`ContainerLauncher`]) and handing out model credentials
3//! ([`LlmAccess`]). Production wires `talon-container`'s
4//! [`ContainerRunner`] and `talon-llm`'s [`GatewayHandle`]; tests use fakes.
5
6use std::fmt;
7use std::sync::Arc;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use talon_container::{ContainerHandle, ContainerRunner, RunResult, RunSpec};
12use talon_core::protocol::{ContainerOutput, LlmEndpoint, OutputStatus, SessionUsage};
13use talon_core::types::{ContainerConfig, VllmConfig};
14use talon_llm::GatewayHandle;
15use talon_scheduler::RunningContainer;
16use tokio::sync::mpsc;
17
18/// Called once with the started container (register it with the queue).
19pub type StartHook = Box<dyn FnOnce(Arc<dyn RunningContainer>) + Send>;
20
21/// Launches one agent container run.
22#[async_trait]
23pub trait ContainerLauncher: Send + Sync {
24    /// Run `spec` to completion. Every output frame is sent, in order, to
25    /// `outputs` (the sender is dropped when the run is over). The channel
26    /// is bounded ([`crate::output_limits::OUTPUT_CHANNEL_CAPACITY`]): a
27    /// launcher waits for room (backpressure) and drops frames once the
28    /// receiver is gone (the orchestrator aborted the run). `on_start`
29    /// receives the container right after it was spawned. Never fails:
30    /// problems are reported in the [`RunResult`].
31    async fn launch(
32        &self,
33        spec: RunSpec,
34        outputs: mpsc::Sender<ContainerOutput>,
35        on_start: StartHook,
36    ) -> RunResult;
37
38    /// Longest a run with `config` may live without producing output (used
39    /// to size the per-run LLM token TTL).
40    fn hard_timeout(&self, config: &ContainerConfig) -> Duration;
41}
42
43/// Per-run model credentials.
44pub trait LlmAccess: Send + Sync {
45    /// Issue a credential for one run (`label` = the lane's IPC key, used
46    /// for usage accounting and the gateway's per-label prefix-cache salt,
47    /// so lanes sharing a group folder never share a cache) and the
48    /// endpoint description for `ContainerInput.llm`. Returns
49    /// `(token, endpoint)`.
50    fn issue(&self, label: &str, ttl: Duration, vllm: &VllmConfig) -> (String, LlmEndpoint);
51
52    /// Revoke a credential when its run is over.
53    fn revoke(&self, token: &str);
54
55    /// Keep a live credential valid for another `ttl` from now. Returns
56    /// false when it cannot be extended (expired, revoked, unsupported).
57    fn extend(&self, _token: &str, _ttl: Duration) -> bool {
58        false
59    }
60}
61
62/// [`RunningContainer`] adapter over a `talon-container` handle.
63pub struct QueueContainer(pub ContainerHandle);
64
65impl fmt::Debug for QueueContainer {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.debug_tuple("QueueContainer").field(&self.0).finish()
68    }
69}
70
71#[async_trait]
72impl RunningContainer for QueueContainer {
73    fn name(&self) -> &str {
74        self.0.name()
75    }
76
77    async fn stop(&self) -> talon_core::Result<()> {
78        self.0.stop().await;
79        Ok(())
80    }
81}
82
83/// Production [`ContainerLauncher`] over [`ContainerRunner`].
84pub struct RunnerLauncher {
85    runner: ContainerRunner,
86}
87
88impl fmt::Debug for RunnerLauncher {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_struct("RunnerLauncher")
91            .field("runner", &self.runner)
92            .finish()
93    }
94}
95
96impl RunnerLauncher {
97    /// Wrap a configured runner.
98    pub fn new(runner: ContainerRunner) -> Self {
99        Self { runner }
100    }
101}
102
103#[async_trait]
104impl ContainerLauncher for RunnerLauncher {
105    async fn launch(
106        &self,
107        spec: RunSpec,
108        outputs: mpsc::Sender<ContainerOutput>,
109        on_start: StartHook,
110    ) -> RunResult {
111        let handler = move |output: ContainerOutput| {
112            let outputs = outputs.clone();
113            async move {
114                // Bounded: wait until the orchestrator took the previous
115                // frames (backpressure towards the stdout reader). The
116                // receiver only disappears when the orchestrator stopped
117                // listening (run aborted); dropping frames is then right.
118                let _ = outputs.send(output).await;
119            }
120        };
121        self.runner
122            .run(spec, handler, move |handle: ContainerHandle| {
123                on_start(Arc::new(QueueContainer(handle)));
124            })
125            .await
126    }
127
128    fn hard_timeout(&self, config: &ContainerConfig) -> Duration {
129        self.runner.config().hard_timeout(config)
130    }
131}
132
133/// Production [`LlmAccess`] over the host LLM gateway.
134#[derive(Debug, Clone)]
135pub struct GatewayLlm {
136    gateway: GatewayHandle,
137    /// Host name containers use to reach the gateway
138    /// (`host.docker.internal`).
139    container_host: String,
140}
141
142impl GatewayLlm {
143    /// Tokens from `gateway`; containers reach it as `container_host`.
144    pub fn new(gateway: GatewayHandle, container_host: impl Into<String>) -> Self {
145        Self {
146            gateway,
147            container_host: container_host.into(),
148        }
149    }
150}
151
152impl LlmAccess for GatewayLlm {
153    fn issue(&self, label: &str, ttl: Duration, vllm: &VllmConfig) -> (String, LlmEndpoint) {
154        let token = self.gateway.issue_token(label, ttl);
155        let endpoint = self
156            .gateway
157            .endpoint(token.clone(), &self.container_host, vllm);
158        (token, endpoint)
159    }
160
161    fn revoke(&self, token: &str) {
162        self.gateway.revoke(token);
163    }
164
165    fn extend(&self, token: &str, ttl: Duration) -> bool {
166        self.gateway.extend(token, ttl)
167    }
168}
169
170/// What the orchestrator learned from one agent run.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct AgentOutcome {
173    /// Overall status (`success` or `error`).
174    pub status: OutputStatus,
175    /// Error message when `status == Error`.
176    pub error: Option<String>,
177    /// Last non-null result streamed by the agent.
178    pub last_result: Option<String>,
179    /// Session id reported by the agent.
180    pub new_session_id: Option<String>,
181    /// Usage reported with the last frame.
182    pub usage: Option<SessionUsage>,
183    /// Whether a container was actually started.
184    pub launched: bool,
185}
186
187impl AgentOutcome {
188    /// A run that failed before any container started.
189    pub fn not_started(error: impl Into<String>) -> Self {
190        Self {
191            status: OutputStatus::Error,
192            error: Some(error.into()),
193            last_result: None,
194            new_session_id: None,
195            usage: None,
196            launched: false,
197        }
198    }
199
200    /// `status == Success`.
201    pub fn is_success(&self) -> bool {
202        self.status == OutputStatus::Success
203    }
204}