Skip to main content

wcode_harness/
agent.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use futures::StreamExt as _;
5use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
6use tokio_util::sync::CancellationToken;
7
8use crate::compaction::{self, CompactOutcome, CompactionPolicy};
9use crate::event::{AgentEvent, LlmStreamEvent};
10use crate::hooks::{HooksSet, PlanModeHandle};
11use crate::loop_::{LoopConfig, LoopError, run_loop};
12use crate::message::{AgentMessage, StopReason, Usage};
13use crate::session::{Session, SessionEntry};
14use crate::streamfn::{LlmOpts, StreamFn};
15use crate::tool::Tool;
16
17/// The system-prompt section appended while plan mode is on (D2). The kernel owns
18/// it so a socket client never has to compose the server's prompt.
19pub const PLAN_SECTION: &str = "# Plan mode\nYou are planning, not executing. Explore and propose only — do NOT modify the\nworkspace: the editing tools are disabled and mutating shell commands are refused.\nFine-tune the plan with the user first. When the plan is final, record it as a\ntodo list (status pending), then tell the user to run `/plan off` to execute.";
20
21pub struct AgentConfig {
22    pub system: String,
23    pub tools: Vec<Tool>,
24    pub llm: LlmOpts,
25    pub stream_fn: StreamFn,
26    pub hooks: HooksSet,
27    pub session: Option<Session>, // None = no persistence
28    /// Prior messages seeding the conversation (e.g. resumed session history).
29    pub context: Vec<AgentMessage>,
30    /// Working directory for tool execution; empty falls back to the process
31    /// cwd (bash runs here, file paths resolve) — plumb it so embeddings can
32    /// run against a directory other than `current_dir()`.
33    pub working_dir: PathBuf,
34    /// Maximum number of LLM turns in a single `run`; caps a runaway tool loop
35    /// (see [`crate::loop_::LoopConfig::max_turns`]). Use
36    /// [`crate::loop_::DEFAULT_MAX_TURNS`] for the kernel default.
37    pub max_turns: usize,
38    /// Run concurrency-safe tool calls in one batch in parallel. `false`
39    /// restores strictly-sequential execution.
40    pub parallel_tools: bool,
41    /// When to compact and how much recent context to keep
42    /// (see [`crate::compaction`]). Use [`CompactionPolicy::default`].
43    pub compaction: CompactionPolicy,
44    /// The shared plan-mode switch; the same handle is held by the
45    /// `PlanModeHooks` in `hooks`, so `set_plan_mode` flips both.
46    pub plan_mode: PlanModeHandle,
47}
48
49pub struct Agent {
50    system: String,
51    /// The composed prompt as passed (plan mode off); `system` derives from it.
52    base_system: String,
53    tools: Vec<Tool>,
54    llm: LlmOpts,
55    stream_fn: StreamFn,
56    hooks: HooksSet,
57    session: Option<Session>,
58    working_dir: PathBuf,
59    max_turns: usize,
60    parallel_tools: bool,
61    compaction: CompactionPolicy,
62    plan_mode: PlanModeHandle,
63    ctx: Vec<AgentMessage>,
64    steer_tx: UnboundedSender<AgentMessage>,
65    steer_rx: Option<UnboundedReceiver<AgentMessage>>,
66    follow_tx: UnboundedSender<AgentMessage>,
67    follow_rx: Option<UnboundedReceiver<AgentMessage>>,
68    cancel: CancellationToken,
69}
70
71impl Agent {
72    pub fn new(mut cfg: AgentConfig) -> Agent {
73        // Stable routing id for providers (OpenCode Go `x-opencode-session`):
74        // session header wins (a resumed Agent inherits `llm` from the old
75        // one, so the opt may be stale), else explicit opt, else a per-agent
76        // id (covers --no-session / in-memory).
77        cfg.llm.session_id = cfg
78            .session
79            .as_ref()
80            .and_then(session_header_id)
81            .or_else(|| cfg.llm.session_id.clone())
82            .or_else(|| Some(uuid::Uuid::new_v4().to_string()));
83        let (steer_tx, steer_rx) = mpsc::unbounded_channel();
84        let (follow_tx, follow_rx) = mpsc::unbounded_channel();
85        // An empty working_dir means "process cwd" (the historical default).
86        let working_dir = if cfg.working_dir.as_os_str().is_empty() {
87            default_working_dir()
88        } else {
89            cfg.working_dir
90        };
91        Agent {
92            system: cfg.system.clone(),
93            base_system: cfg.system,
94            tools: cfg.tools,
95            llm: cfg.llm,
96            stream_fn: cfg.stream_fn,
97            hooks: cfg.hooks,
98            session: cfg.session,
99            working_dir,
100            max_turns: cfg.max_turns,
101            parallel_tools: cfg.parallel_tools,
102            compaction: cfg.compaction,
103            plan_mode: cfg.plan_mode,
104            ctx: cfg.context,
105            steer_tx,
106            steer_rx: Some(steer_rx),
107            follow_tx,
108            follow_rx: Some(follow_rx),
109            cancel: CancellationToken::new(),
110        }
111    }
112
113    /// Queued for injection at the next turn start (this run or a later one).
114    pub fn steer(&self, m: AgentMessage) {
115        let _ = self.steer_tx.send(m);
116    }
117
118    /// Toggle plan mode: flips the shared handle and recomposes `system` from
119    /// `base_system` — appending [`PLAN_SECTION`] when on, restoring the base
120    /// prompt exactly when off. Takes effect on the NEXT run (the in-flight one
121    /// cloned `system` into its `LoopConfig` at start). Not persisted.
122    pub fn set_plan_mode(&mut self, on: bool) {
123        self.plan_mode.set(on);
124        self.system = if on {
125            format!("{}\n\n{}", self.base_system, PLAN_SECTION)
126        } else {
127            self.base_system.clone()
128        };
129    }
130    /// Swaps the model mid-conversation (takes effect on the next run) and
131    /// logs a `ModelChange` entry when a session is open. The new id's
132    /// `[models.<id>]` profile re-points `endpoint`/`base_url`/`api_key` by
133    /// settling on the launch provider first, so an unmapped id — and a switch
134    /// back to a previous model — falls back to it (the switch is reversible).
135    pub fn set_model(&mut self, model: String) -> std::io::Result<()> {
136        // Log first: a failed append must not leave the agent and the session
137        // disagreeing about which model runs next.
138        if let Some(session) = &mut self.session {
139            session.append(SessionEntry::ModelChange {
140                id: uuid::Uuid::new_v4().to_string(),
141                model: model.clone(),
142            })?;
143        }
144        self.llm.model = model;
145        // Settle the new id's provider: the launch provider, then this model's
146        // `[models.<id>]` overlay (a no-op when no profiles are configured).
147        self.llm.settle_provider();
148        Ok(())
149    }
150
151    /// Current reasoning effort (None = send nothing).
152    pub fn effort(&self) -> Option<&str> {
153        self.llm.effort.as_deref()
154    }
155
156    /// Swaps the reasoning effort mid-conversation (next run) and logs an
157    /// `EffortChange` entry when a session is open. None clears it.
158    pub fn set_effort(&mut self, effort: Option<String>) -> std::io::Result<()> {
159        if let Some(session) = &mut self.session {
160            session.append(SessionEntry::EffortChange {
161                id: uuid::Uuid::new_v4().to_string(),
162                effort: effort.clone(),
163            })?;
164        }
165        self.llm.effort = effort;
166        Ok(())
167    }
168
169    /// Sender clone for steering while `run` holds the `&mut` borrow (UI tasks).
170    ///
171    /// `run_loop` hands the lent channels back at the end of every run, so a
172    /// clone taken once stays valid across runs and a message sent while a run
173    /// is in flight — even one the loop never drained — reaches the next run.
174    /// (Only an *error* exit reboots the channels, invalidating old clones.)
175    pub fn steer_sender(&self) -> UnboundedSender<AgentMessage> {
176        self.steer_tx.clone()
177    }
178
179    /// Runs after the loop would stop.
180    pub fn follow_up(&self, m: AgentMessage) {
181        let _ = self.follow_tx.send(m);
182    }
183
184    /// Sender clone for injecting a follow-up while `run` holds the `&mut`
185    /// borrow (the mirror of [`Agent::steer_sender`], for a UI/actor task that
186    /// cannot reach `follow_up` directly).
187    pub fn follow_up_sender(&self) -> UnboundedSender<AgentMessage> {
188        self.follow_tx.clone()
189    }
190
191    pub fn cancel(&self) {
192        self.cancel.cancel();
193    }
194
195    /// Token for the run in flight or the next one; re-acquire after each
196    /// `run()` (a fresh token is minted at the end of every run).
197    pub fn cancel_token(&self) -> CancellationToken {
198        self.cancel.clone()
199    }
200
201    /// The hook set, exposed so the actor can run the inbound policy
202    /// ([`HooksSet::before_inbound`]) while `run` holds the `&mut` borrow.
203    pub fn hooks(&self) -> &HooksSet {
204        &self.hooks
205    }
206
207    pub async fn run(
208        &mut self,
209        user_text: &str,
210        sink: UnboundedSender<AgentEvent>,
211    ) -> Result<StopReason, LoopError> {
212        // The user message is persisted before the loop takes over; the loop
213        // records every subsequent message as it enters ctx, so a crash
214        // mid-run loses at most the in-flight message instead of the whole
215        // turn (see `record_session` in loop_).
216        let user = AgentMessage::user_text(user_text);
217        if let Some(session) = &mut self.session {
218            session
219                .append(SessionEntry::Message {
220                    id: uuid::Uuid::new_v4().to_string(),
221                    parent_id: None,
222                    message: user.clone(),
223                })
224                .map_err(LoopError::Session)?;
225        }
226        self.ctx.push(user);
227
228        let steering = self.steer_rx.take().expect("steer_rx present between runs");
229        let follow_ups = self
230            .follow_rx
231            .take()
232            .expect("follow_rx present between runs");
233        let cfg = LoopConfig {
234            system: self.system.clone(),
235            tools: self.tools.clone(),
236            llm: self.llm.clone(),
237            stream_fn: Arc::clone(&self.stream_fn),
238            hooks: self.hooks.clone(),
239            steering,
240            follow_ups,
241            cancel: self.cancel.clone(),
242            working_dir: self.working_dir.clone(),
243            max_turns: self.max_turns,
244            parallel: self.parallel_tools,
245            compaction: self.compaction,
246            session: self.session.as_mut(),
247        };
248        let res = run_loop(&mut self.ctx, cfg, sink).await;
249
250        // Fresh token for the next run: CancellationToken has no reset, so a
251        // cancel from this run must not poison the next one.
252        self.cancel = CancellationToken::new();
253
254        match res {
255            Ok(result) => {
256                // run_loop hands the lent channels back, so steer()/follow_up()
257                // and any sender clone taken before the run keep working, and a
258                // message sent while the run was in flight — even one the loop
259                // never drained — reaches the next run.
260                self.steer_rx = Some(result.steering);
261                self.follow_rx = Some(result.follow_ups);
262                // An `after_run` hook may act on the finished run (the A2A
263                // auto-report forwards a worker's result to its orchestrator).
264                self.hooks.after_run(&self.ctx, result.stop_reason).await;
265                Ok(result.stop_reason)
266            }
267            Err(e) => {
268                // Error exit: the channels were dropped with the error. Reboot
269                // both sides so a later steer()/follow_up() still work
270                // (pre-run sender clones are stale after an error).
271                let (steer_tx, steer_rx) = mpsc::unbounded_channel();
272                self.steer_tx = steer_tx;
273                self.steer_rx = Some(steer_rx);
274                let (follow_tx, follow_rx) = mpsc::unbounded_channel();
275                self.follow_tx = follow_tx;
276                self.follow_rx = Some(follow_rx);
277                Err(e)
278            }
279        }
280    }
281
282    pub fn messages(&self) -> &[AgentMessage] {
283        &self.ctx
284    }
285
286    /// Record an inbound peer message into the conversation *without* starting a
287    /// turn (the `Request::Notify` semantic): persist it as a user message and
288    /// push it onto the context, so the next run sees it. Returns the recorded
289    /// message so the actor can surface it. Idle-only — the actor routes a
290    /// `Notify` that arrives mid-run through steering instead.
291    pub fn notify(&mut self, content: String) -> std::io::Result<AgentMessage> {
292        let message = AgentMessage::user_text(content);
293        if let Some(session) = &mut self.session {
294            session.append(SessionEntry::Message {
295                id: uuid::Uuid::new_v4().to_string(),
296                parent_id: None,
297                message: message.clone(),
298            })?;
299        }
300        self.ctx.push(message.clone());
301        Ok(message)
302    }
303
304    /// Answer `text` tool-free, from the current context, as a one-off side call.
305    ///
306    /// CLONES `ctx`, appends the (framed) question, runs the raw `stream_fn` once
307    /// with an EMPTY tool list, and folds `TextDelta` → text until `Done`
308    /// (capturing `usage`). `self.ctx` and `self.session` are never mutated or
309    /// appended — hence `&self`, so it stays callable while the actor holds
310    /// `&mut Agent` for a run.
311    ///
312    /// Errors become [`LoopError::Stream`]: a stream `Error`, or a call that
313    /// ended with no text. No retry loop of its own — it inherits whatever
314    /// retry/idle-timeout the configured `stream_fn` adapter applies internally,
315    /// exactly as [`crate::compaction::summarize`] does. No cancel path in v1.
316    pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
317        let mut msgs = self.ctx.clone();
318        msgs.push(AgentMessage::user_text(frame_btw(text)));
319        let mut stream = (self.stream_fn)(&msgs, &self.system, &[], &self.llm);
320        let mut out = String::new();
321        let mut usage = None;
322        while let Some(ev) = stream.next().await {
323            match ev {
324                LlmStreamEvent::TextDelta(delta) => out.push_str(&delta),
325                LlmStreamEvent::Done { usage: u, .. } => {
326                    usage = u;
327                    break;
328                }
329                LlmStreamEvent::Error { message, .. } => return Err(LoopError::Stream(message)),
330                _ => {}
331            }
332        }
333        let text = out.trim().to_string();
334        if text.is_empty() {
335            return Err(LoopError::Stream("side answer produced no text".to_string()));
336        }
337        Ok(SideAnswer { text, usage })
338    }
339    /// Summarize the older part of the conversation in place: keep the newest
340    /// messages per [`CompactionPolicy`] and replace the rest with a single
341    /// summary message. `instructions` focuses the summary (the
342    /// `/compact <prompt>` argument).
343    ///
344    /// When a session is open, the boundary is persisted
345    /// ([`crate::session::SessionEntry::Compaction`]), so a resumed session
346    /// rebuilds to the same `summary + kept` view instead of re-expanding.
347    pub async fn compact(&mut self, instructions: Option<&str>) -> Result<CompactOutcome, String> {
348        compaction::compact_ctx(
349            &mut self.ctx,
350            &self.compaction,
351            &self.stream_fn,
352            &self.llm,
353            instructions,
354            self.session.as_mut(),
355        )
356        .await
357    }
358
359    pub fn session_path(&self) -> Option<&Path> {
360        self.session.as_ref().and_then(|s| s.path())
361    }
362
363    /// The session's stable id (from its `Header`), if a session is open. The
364    /// address a [`SessionHandle`](crate::actor::SessionHandle)'s frames carry.
365    pub fn session_id(&self) -> Option<String> {
366        self.session.as_ref().and_then(session_header_id)
367    }
368}
369
370/// The reply to a `/btw` side question: the answer text and the turn's token
371/// usage (`None` when the provider reported none). Harness data — no styling.
372#[derive(Clone, Debug, Default, PartialEq)]
373pub struct SideAnswer {
374    pub text: String,
375    pub usage: Option<Usage>,
376}
377
378/// Frame a raw `/btw` question so the model knows it is a tool-free aside: the
379/// tools are simply absent, so without a hint it may try to call one and fail.
380fn frame_btw(q: &str) -> String {
381    format!("[side question — no tools available; answer briefly and directly from the conversation above]\n{q}")
382}
383
384/// Process cwd, used only when `AgentConfig.working_dir` is left empty.
385fn default_working_dir() -> PathBuf {
386    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
387}
388
389/// Id from the session's `Header` entry, if present (old files may lack one).
390fn session_header_id(session: &Session) -> Option<String> {
391    session.entries().iter().find_map(|e| match e {
392        SessionEntry::Header { id, .. } => Some(id.clone()),
393        _ => None,
394    })
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::compaction::CompactionPolicy;
401    use crate::hooks::HooksSet;
402    use crate::streamfn::{LlmStream, StreamFn};
403
404    /// A `StreamFn` that replays `script` on each call.
405    fn scripted(script: Vec<LlmStreamEvent>) -> StreamFn {
406        Arc::new(move |_ctx: &[AgentMessage], _system, _tools, _opts: &LlmOpts| {
407            Box::pin(futures::stream::iter(script.clone())) as LlmStream
408        })
409    }
410
411    fn make_agent(session: Option<Session>, stream_fn: StreamFn, context: Vec<AgentMessage>) -> Agent {
412        Agent::new(AgentConfig {
413            system: "sys".into(),
414            tools: vec![],
415            llm: LlmOpts {
416                model: "m1".into(),
417                ..LlmOpts::default()
418            },
419            stream_fn,
420            hooks: HooksSet::default(),
421            session,
422            context,
423            working_dir: PathBuf::new(),
424            max_turns: crate::loop_::DEFAULT_MAX_TURNS,
425            parallel_tools: true,
426            compaction: CompactionPolicy::default(),
427            plan_mode: PlanModeHandle::new(),
428        })
429    }
430
431    #[tokio::test]
432    async fn side_ask_leaves_ctx_and_session_untouched() {
433        let dir = tempfile::tempdir().unwrap();
434        let session = Session::create(dir.path()).unwrap();
435        let stream = scripted(vec![
436            LlmStreamEvent::TextDelta("an".into()),
437            LlmStreamEvent::TextDelta("swer".into()),
438            LlmStreamEvent::Done {
439                stop_reason: StopReason::Stop,
440                usage: None,
441            },
442        ]);
443        let agent = make_agent(Some(session), stream, vec![AgentMessage::user_text("earlier")]);
444
445        let ctx_before = agent.ctx.clone();
446        let path = agent.session.as_ref().unwrap().path().unwrap().to_path_buf();
447        let entries_before = agent.session.as_ref().unwrap().entries().to_vec();
448        let bytes_before = std::fs::read(&path).unwrap();
449
450        let answer = agent.side_ask("why?").await.unwrap();
451        assert_eq!(answer.text, "answer");
452
453        assert_eq!(agent.ctx, ctx_before, "ctx is unchanged");
454        assert_eq!(agent.messages(), &ctx_before[..]);
455        assert_eq!(
456            agent.session.as_ref().unwrap().entries(),
457            &entries_before[..],
458            "session entries unchanged"
459        );
460        assert_eq!(
461            std::fs::read(&path).unwrap(),
462            bytes_before,
463            "session file bytes unchanged"
464        );
465    }
466
467    #[tokio::test]
468    async fn side_ask_folds_deltas_and_captures_usage() {
469        let usage = Usage {
470            input_tokens: 7,
471            output_tokens: 3,
472            cache_read_tokens: Some(2),
473            cache_write_tokens: None,
474        };
475        let stream = scripted(vec![
476            LlmStreamEvent::TextDelta("he".into()),
477            LlmStreamEvent::TextDelta("llo".into()),
478            LlmStreamEvent::Done {
479                stop_reason: StopReason::Stop,
480                usage: Some(usage),
481            },
482        ]);
483        let agent = make_agent(None, stream, vec![]);
484        assert_eq!(
485            agent.side_ask("hi").await.unwrap(),
486            SideAnswer {
487                text: "hello".into(),
488                usage: Some(usage),
489            }
490        );
491    }
492
493    #[tokio::test]
494    async fn side_ask_surfaces_a_stream_error() {
495        let stream = scripted(vec![LlmStreamEvent::Error {
496            message: "boom".into(),
497            fatal: true,
498        }]);
499        let agent = make_agent(None, stream, vec![]);
500        let err = agent.side_ask("hi").await.unwrap_err();
501        assert!(err.to_string().contains("boom"), "{err}");
502    }
503
504    #[tokio::test]
505    async fn side_ask_errors_on_empty_output() {
506        let stream = scripted(vec![LlmStreamEvent::Done {
507            stop_reason: StopReason::Stop,
508            usage: None,
509        }]);
510        let agent = make_agent(None, stream, vec![]);
511        let err = agent.side_ask("hi").await.unwrap_err();
512        assert!(err.to_string().contains("no text"), "{err}");
513    }
514
515    #[test]
516    fn frame_btw_wraps_the_question() {
517        let framed = frame_btw("why?");
518        assert!(framed.starts_with("[side question"));
519        assert!(framed.ends_with("\nwhy?"));
520    }
521
522    #[test]
523    fn set_plan_mode_appends_and_restores_the_section() {
524        let mut agent = make_agent(None, scripted(vec![]), vec![]);
525        let base = agent.system.clone();
526
527        agent.set_plan_mode(true);
528        assert!(agent.plan_mode.get());
529        assert_eq!(agent.system, format!("{base}\n\n{PLAN_SECTION}"));
530        assert!(agent.system.contains("# Plan mode"));
531        assert!(
532            agent.system.contains("record it as a\ntodo list (status pending)"),
533            "the P2 plan\u{2192}todo instruction is present"
534        );
535
536        agent.set_plan_mode(false);
537        assert!(!agent.plan_mode.get());
538        assert_eq!(agent.system, base, "restored exactly");
539    }
540}