1use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11
12use serde::Deserialize;
13use serde_json::{json, Value};
14
15use crate::actor::{component, ingest, resume, Actor, ErasedComponent, Settlement, Transition};
16use crate::effect::{Effect, Schedule};
17use crate::error::ActorError;
18use crate::exit::Exit;
19use crate::fact::{Fact, NewFact};
20
21pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ToolSpec {
25 pub name: String,
26 pub description: String,
27}
28
29#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
30pub struct ToolCall {
31 pub id: String,
32 pub name: String,
33 pub args: Value,
34 pub needs_confirmation: bool,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub text: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub reasoning: Option<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
46#[serde(tag = "kind", rename_all = "snake_case")]
47pub enum ModelDecision {
48 Text {
49 text: String,
50 },
51 Tool {
52 call: ToolCall,
53 },
54 Question {
55 question_id: String,
56 question: String,
57 allow_free_text: bool,
58 #[serde(default)]
61 options: Vec<String>,
62 },
63}
64
65#[derive(Debug, Clone)]
66pub struct CompletionRequest {
67 pub system: Vec<String>,
68 pub tools: Vec<ToolSpec>,
69 pub summary: String,
70 pub messages: Vec<String>,
71}
72
73pub trait Completion: Send + Sync {
74 fn complete(&self, request: CompletionRequest) -> BoxFuture<Result<ModelDecision, ActorError>>;
75}
76
77pub trait ToolRunner: Send + Sync {
78 fn run(&self, call: ToolCall) -> BoxFuture<Result<Value, ActorError>>;
79}
80
81pub trait Compactor: Send + Sync {
82 fn compact(&self, messages: &[String]) -> BoxFuture<Result<String, ActorError>>;
83}
84
85#[derive(Clone)]
86pub struct CodingServices {
87 pub completion: Arc<dyn Completion>,
88 pub tools: Arc<dyn ToolRunner>,
89 pub compactor: Arc<dyn Compactor>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct HarnessConfig {
96 budget: u32,
97 compact_after_chars: usize,
98 step_limit: u32,
99 model_attempts: u32,
100 system: Vec<String>,
101 tools: Vec<ToolSpec>,
102 tool_round_cap: Option<u32>,
105}
106
107impl HarnessConfig {
108 pub fn new(
109 budget: u32,
110 compact_after_chars: usize,
111 step_limit: u32,
112 model_attempts: u32,
113 system: Vec<String>,
114 tools: Vec<ToolSpec>,
115 ) -> Result<Self, ActorError> {
116 if step_limit == 0 {
117 return Err(ActorError::Config("step_limit must be at least 1".into()));
118 }
119 if model_attempts == 0 {
120 return Err(ActorError::Config(
121 "model_attempts must be at least 1".into(),
122 ));
123 }
124 Ok(Self {
125 budget,
126 compact_after_chars,
127 step_limit,
128 model_attempts,
129 system,
130 tools,
131 tool_round_cap: None,
132 })
133 }
134
135 pub fn with_tool_round_cap(mut self, cap: u32) -> Self {
138 self.tool_round_cap = Some(cap);
139 self
140 }
141
142 pub fn step_limit(&self) -> u32 {
143 self.step_limit
144 }
145
146 pub fn budget(&self) -> u32 {
147 self.budget
148 }
149
150 pub fn compact_after_chars(&self) -> usize {
151 self.compact_after_chars
152 }
153
154 pub fn model_attempts(&self) -> u32 {
155 self.model_attempts
156 }
157
158 pub fn system(&self) -> &[String] {
159 &self.system
160 }
161
162 pub fn tools(&self) -> &[ToolSpec] {
163 &self.tools
164 }
165
166 pub fn tool_round_cap(&self) -> Option<u32> {
167 self.tool_round_cap
168 }
169}
170
171#[derive(Debug, Clone, PartialEq)]
172pub struct PendingConfirmation {
173 pub tool_call_id: String,
174 pub name: String,
175 pub args: Value,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct PendingQuestion {
180 pub question_id: String,
181 pub question: String,
182 pub allow_free_text: bool,
183 pub options: Vec<String>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum CodingPhase {
188 Idle,
189 Infer,
190 Compact,
191 Confirm,
192 Question,
193 Tool,
194 Deny,
195 Done,
196}
197
198#[derive(Debug, Clone, PartialEq)]
199pub struct CodingView {
200 pub system: Vec<String>,
201 pub tools: Vec<ToolSpec>,
202 pub pending_confirmation: Option<PendingConfirmation>,
203 pub pending_question: Option<PendingQuestion>,
204 pub assistant: Option<String>,
205 pub phase: CodingPhase,
206 pub schema_error: Option<String>,
207 pub tool_budget: Option<u32>,
209 pub compact_after_chars: Option<usize>,
211}
212
213impl CodingView {
214 pub fn empty() -> Self {
215 Self {
216 system: Vec::new(),
217 tools: Vec::new(),
218 pending_confirmation: None,
219 pending_question: None,
220 assistant: None,
221 phase: CodingPhase::Idle,
222 schema_error: None,
223 tool_budget: None,
224 compact_after_chars: None,
225 }
226 }
227}
228
229pub fn merge_coding_view(views: Vec<CodingView>) -> CodingView {
230 CodingView {
231 system: views.iter().flat_map(|view| view.system.clone()).collect(),
232 tools: views.iter().flat_map(|view| view.tools.clone()).collect(),
233 pending_confirmation: views
234 .iter()
235 .find_map(|view| view.pending_confirmation.clone()),
236 pending_question: views.iter().find_map(|view| view.pending_question.clone()),
237 assistant: views.iter().rev().find_map(|view| view.assistant.clone()),
238 phase: views
239 .iter()
240 .rev()
241 .find(|view| view.phase != CodingPhase::Idle)
242 .map(|view| view.phase)
243 .unwrap_or(CodingPhase::Idle),
244 schema_error: views.iter().find_map(|view| view.schema_error.clone()),
245 tool_budget: views.iter().find_map(|view| view.tool_budget),
246 compact_after_chars: views.iter().find_map(|view| view.compact_after_chars),
247 }
248}
249
250#[derive(Debug, Clone)]
251struct SchedulerState {
252 turn: u64,
253 cycle: u64,
254 phase: CodingPhase,
255 messages: Vec<String>,
256 summary: String,
257 tool_runs: u32,
258 compacted_turn: u64,
259 assistant: Option<String>,
260 pending_call: Option<ToolCall>,
261 pending_question: Option<PendingQuestion>,
262 schema_error: Option<String>,
263}
264
265impl SchedulerState {
266 fn new() -> Self {
267 Self {
268 turn: 0,
269 cycle: 0,
270 phase: CodingPhase::Idle,
271 messages: Vec::new(),
272 summary: String::new(),
273 tool_runs: 0,
274 compacted_turn: 0,
275 assistant: None,
276 pending_call: None,
277 pending_question: None,
278 schema_error: None,
279 }
280 }
281
282 fn size(&self) -> usize {
283 self.summary.len() + self.messages.iter().map(String::len).sum::<usize>()
284 }
285}
286
287fn after_input(state: SchedulerState, config: &HarnessConfig) -> SchedulerState {
288 let needs_compact =
289 state.size() >= config.compact_after_chars && state.compacted_turn != state.turn;
290 SchedulerState {
291 phase: if needs_compact {
292 CodingPhase::Compact
293 } else {
294 CodingPhase::Infer
295 },
296 assistant: None,
297 pending_call: None,
298 pending_question: None,
299 ..state
300 }
301}
302
303fn on_model(mut state: SchedulerState, config: &HarnessConfig, payload: &Value) -> SchedulerState {
304 let decided = match serde_json::from_value::<ModelDecision>(payload.clone()) {
305 Ok(decided) => decided,
306 Err(error) => {
307 state.phase = CodingPhase::Done;
308 state.schema_error = Some(error.to_string());
309 state.pending_call = None;
310 return state;
311 }
312 };
313 match decided {
314 ModelDecision::Text { text } => SchedulerState {
315 phase: CodingPhase::Done,
316 assistant: Some(text),
317 pending_call: None,
318 ..state
319 },
320 ModelDecision::Question {
321 question_id,
322 question,
323 allow_free_text,
324 options,
325 } => SchedulerState {
326 phase: CodingPhase::Question,
327 pending_question: Some(PendingQuestion {
328 question_id,
329 question,
330 allow_free_text,
331 options,
332 }),
333 pending_call: None,
334 ..state
335 },
336 ModelDecision::Tool { call } => {
337 if state.tool_runs >= config.budget {
338 SchedulerState {
339 phase: CodingPhase::Deny,
340 pending_call: Some(call),
341 ..state
342 }
343 } else if call.needs_confirmation {
344 SchedulerState {
345 phase: CodingPhase::Confirm,
346 pending_call: Some(call),
347 ..state
348 }
349 } else {
350 SchedulerState {
351 phase: CodingPhase::Tool,
352 pending_call: Some(call),
353 ..state
354 }
355 }
356 }
357 }
358}
359
360#[derive(Deserialize)]
361struct TextPayload {
362 text: String,
363}
364
365#[derive(Deserialize)]
366struct SummaryPayload {
367 summary: String,
368}
369
370#[derive(Deserialize)]
371struct ConfirmationPayload {
372 tool_call_id: String,
373 approved: bool,
374}
375
376#[derive(Deserialize)]
377struct ToolResultPayload {
378 ok: bool,
379 output: String,
380}
381
382fn step(mut state: SchedulerState, fact: &Fact, config: &HarnessConfig) -> SchedulerState {
383 match fact.kind.as_str() {
384 "user.message" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
385 Ok(payload) => after_input(
386 SchedulerState {
387 turn: state.turn + 1,
388 cycle: 0,
389 messages: {
390 let mut messages = state.messages.clone();
391 messages.push(format!("user\n{}", payload.text));
392 messages
393 },
394 tool_runs: 0,
395 ..state
396 },
397 config,
398 ),
399 Err(error) => {
400 state.phase = CodingPhase::Done;
401 state.schema_error = Some(error.to_string());
402 state
403 }
404 },
405 "compaction.done" => match serde_json::from_value::<SummaryPayload>(fact.payload.clone()) {
406 Ok(payload) => SchedulerState {
407 summary: payload.summary,
408 compacted_turn: state.turn,
409 phase: CodingPhase::Infer,
410 messages: Vec::new(),
411 ..state
412 },
413 Err(error) => {
414 state.phase = CodingPhase::Done;
415 state.schema_error = Some(error.to_string());
416 state
417 }
418 },
419 "model.turn" => on_model(state, config, &fact.payload),
420 "confirmation.answered" => {
421 let Some(call) = state.pending_call.clone() else {
422 return state;
423 };
424 match serde_json::from_value::<ConfirmationPayload>(fact.payload.clone()) {
425 Ok(payload) if payload.tool_call_id == call.id && payload.approved => {
426 SchedulerState {
427 phase: CodingPhase::Tool,
428 pending_question: None,
429 ..state
430 }
431 }
432 Ok(payload) if payload.tool_call_id == call.id => SchedulerState {
433 phase: CodingPhase::Done,
434 assistant: Some("denied".into()),
435 pending_call: None,
436 ..state
437 },
438 Ok(_) => state,
439 Err(error) => {
440 state.schema_error = Some(error.to_string());
441 state.phase = CodingPhase::Done;
442 state
443 }
444 }
445 }
446 "tool.result" => match serde_json::from_value::<ToolResultPayload>(fact.payload.clone()) {
447 Ok(payload) => after_input(
448 SchedulerState {
449 tool_runs: state.tool_runs + u32::from(payload.ok),
450 cycle: state.cycle + 1,
451 messages: {
452 let mut messages = state.messages.clone();
453 messages.push(format!("tool\n{}", payload.output));
454 messages
455 },
456 pending_call: None,
457 ..state
458 },
459 config,
460 ),
461 Err(error) => {
462 state.schema_error = Some(error.to_string());
463 state.phase = CodingPhase::Done;
464 state
465 }
466 },
467 "question.answered" => match serde_json::from_value::<TextPayload>(fact.payload.clone()) {
468 Ok(payload) => after_input(
469 SchedulerState {
470 cycle: state.cycle + 1,
471 messages: {
472 let mut messages = state.messages.clone();
473 messages.push(format!("user\n{}", payload.text));
474 messages
475 },
476 pending_question: None,
477 ..state
478 },
479 config,
480 ),
481 Err(error) => {
482 state.schema_error = Some(error.to_string());
483 state.phase = CodingPhase::Done;
484 state
485 }
486 },
487 "budget.denied" => SchedulerState {
488 phase: CodingPhase::Done,
489 assistant: Some("budget".into()),
490 pending_call: None,
491 ..state
492 },
493 _ => state,
494 }
495}
496
497fn view_of(state: &SchedulerState) -> CodingView {
498 CodingView {
499 pending_confirmation: match (&state.phase, &state.pending_call) {
500 (CodingPhase::Confirm, Some(call)) => Some(PendingConfirmation {
501 tool_call_id: call.id.clone(),
502 name: call.name.clone(),
503 args: call.args.clone(),
504 }),
505 _ => None,
506 },
507 pending_question: match state.phase {
508 CodingPhase::Question => state.pending_question.clone(),
509 _ => None,
510 },
511 assistant: state.assistant.clone(),
512 phase: state.phase,
513 schema_error: state.schema_error.clone(),
514 ..CodingView::empty()
515 }
516}
517
518fn transitions_of(
519 state: &SchedulerState,
520 config: &HarnessConfig,
521) -> Vec<Transition<CodingServices>> {
522 match state.phase {
523 CodingPhase::Compact => {
524 let mut messages = state.messages.clone();
525 if !state.summary.is_empty() {
526 messages.insert(0, state.summary.clone());
527 }
528 let turn = state.turn;
529 vec![Transition {
530 key: format!("compact:{turn}"),
531 run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
532 let messages = messages.clone();
533 async move {
534 let summary = services.compactor.compact(&messages).await?;
535 Ok(vec![NewFact {
536 kind: "compaction.done".into(),
537 key: format!("compaction:{turn}"),
538 payload: json!({ "summary": summary }),
539 }])
540 }
541 }),
542 }]
543 }
544 CodingPhase::Infer => {
545 let tools = match config.tool_round_cap {
546 Some(cap) if state.tool_runs >= cap => Vec::new(),
547 _ => config.tools.clone(),
548 };
549 let request = CompletionRequest {
550 system: config.system.clone(),
551 tools,
552 summary: state.summary.clone(),
553 messages: state.messages.clone(),
554 };
555 let turn = state.turn;
556 let cycle = state.cycle;
557 let attempts = config.model_attempts;
558 vec![Transition {
559 key: format!("infer:{turn}:{cycle}"),
560 run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
561 let request = request.clone();
562 async move {
563 let decided = match services.completion.complete(request).await {
564 Ok(decided) => decided,
565 Err(ActorError::Defect(message)) => return Err(Exit::Die(message)),
566 Err(error) => return Err(Exit::Fail(error)),
567 };
568 Ok(vec![NewFact {
569 kind: "model.turn".into(),
570 key: format!("model:{turn}:{cycle}"),
571 payload: serde_json::to_value(&decided)
572 .map_err(|error| Exit::Die(error.to_string()))?,
573 }])
574 }
575 })
576 .retry(Schedule {
577 remaining: attempts.saturating_sub(1),
578 delay: std::time::Duration::ZERO,
579 }),
580 }]
581 }
582 CodingPhase::Tool => {
583 let Some(call) = state.pending_call.clone() else {
584 return Vec::new();
585 };
586 let turn = state.turn;
587 let cycle = state.cycle;
588 vec![Transition {
589 key: format!("tool:{turn}:{cycle}:{}", call.id),
590 run: Effect::from_async(move |services: Arc<CodingServices>, _cancel| {
591 let call = call.clone();
592 async move {
593 let output = services.tools.run(call.clone()).await?;
594 let output = match output {
595 Value::String(text) => text,
596 other => other.to_string(),
597 };
598 Ok(vec![NewFact {
599 kind: "tool.result".into(),
600 key: format!("tool-result:{turn}:{cycle}:{}", call.id),
601 payload: json!({ "toolCallId": call.id, "ok": true, "output": output }),
602 }])
603 }
604 }),
605 }]
606 }
607 CodingPhase::Deny => {
608 let Some(call) = state.pending_call.clone() else {
609 return Vec::new();
610 };
611 vec![Transition {
612 key: format!("budget:{}:{}:{}", state.turn, state.cycle, call.id),
613 run: Effect::succeed(vec![NewFact {
614 kind: "budget.denied".into(),
615 key: format!("budget-denied:{}:{}:{}", state.turn, state.cycle, call.id),
616 payload: json!({ "toolCallId": call.id }),
617 }]),
618 }]
619 }
620 CodingPhase::Idle | CodingPhase::Confirm | CodingPhase::Question | CodingPhase::Done => {
621 Vec::new()
622 }
623 }
624}
625
626pub fn coding_scheduler(config: HarnessConfig) -> ErasedComponent<CodingServices, CodingView> {
628 let step_config = config.clone();
629 let output_config = config;
630 component(
631 SchedulerState::new,
632 move |state, fact| step(state, fact, &step_config),
633 move |state| (view_of(state), transitions_of(state, &output_config)),
634 )
635}
636
637pub fn coding_actor(config: HarnessConfig) -> Actor<CodingServices, CodingView> {
642 let spec = crate::compose::MetaHarnessSpec {
643 name: "a3s-code",
644 budget: config.budget(),
645 compact_after_chars: config.compact_after_chars(),
646 step_limit: config.step_limit(),
647 model_attempts: config.model_attempts(),
648 system: config.system().to_vec(),
649 tools: config.tools().to_vec(),
650 tool_round_cap: config.tool_round_cap(),
651 parts: Vec::new(),
652 };
653 crate::compose::HarnessGraph::from_spec(spec, || coding_scheduler(config.clone())).into_actor()
654}
655
656pub fn message_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
657 NewFact {
658 kind: "user.message".into(),
659 key: key.into(),
660 payload: json!({ "text": text.into() }),
661 }
662}
663
664pub fn confirm_fact(
665 key: impl Into<String>,
666 tool_call_id: impl Into<String>,
667 approved: bool,
668) -> NewFact {
669 NewFact {
670 kind: "confirmation.answered".into(),
671 key: key.into(),
672 payload: json!({ "tool_call_id": tool_call_id.into(), "approved": approved }),
673 }
674}
675
676pub fn answer_fact(key: impl Into<String>, text: impl Into<String>) -> NewFact {
677 NewFact {
678 kind: "question.answered".into(),
679 key: key.into(),
680 payload: json!({ "text": text.into() }),
681 }
682}
683
684pub async fn ingest_coding(
685 actor: &Actor<CodingServices, CodingView>,
686 log: &dyn crate::log::LogStore,
687 services: Arc<CodingServices>,
688 thread_id: &str,
689 fact: NewFact,
690 limit: u32,
691) -> Result<Settlement<CodingView>, Exit<ActorError>> {
692 ingest(actor, log, services, thread_id, fact, limit).await
693}
694
695pub async fn resume_coding(
696 actor: &Actor<CodingServices, CodingView>,
697 log: &dyn crate::log::LogStore,
698 services: Arc<CodingServices>,
699 thread_id: &str,
700 limit: u32,
701) -> Result<Settlement<CodingView>, Exit<ActorError>> {
702 resume(actor, log, services, thread_id, limit).await
703}