Repository navigation
Expand file tree
/
Copy pathrunner.rs
More file actions
5078 lines (4533 loc) · 199 KB
/
Copy pathrunner.rs
File metadata and controls
5078 lines (4533 loc) · 199 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Multi-turn completion execution loop.
//!
//! [`CompletionRunner`] drives one conversation round after another against the
//! resolved [`Model`]: executing pending tool and agent calls, interleaving
//! queued steering and follow-up input, merging discovered tool schemas,
//! accounting usage, compacting context via handoff, and shaping intermediate
//! and final outputs. [`CompletionStream`] adapts a runner to the [`Stream`]
//! contract. Runners are constructed through
//! [`AgentCtx::completion_iter`](super::AgentCtx::completion_iter) or
//! [`AgentCtx::completion_stream`](super::AgentCtx::completion_stream).
use anda_core::{
AgentContext, AgentInput, AgentOutput, BoxError, BoxPinFut, CompletionRequest, ContentPart,
FunctionDefinition, Json, Message, ModelEffort, Resource, StateFeatures, ToolCall, ToolInput,
ToolOutput, Usage,
};
use futures_util::Stream;
use serde_json::json;
use std::{
collections::{BTreeSet, HashMap, VecDeque},
pin::Pin,
task::{Context, Poll},
};
use super::compaction::{
self, COMPACT_CONTEXT_TOOL, COMPACTION_CONTINUE_PROMPT, CompactContextArgs, CompactionState,
SelfCompaction, TurnInput,
};
use super::tool::DiscoveredTools;
use crate::context::{
AgentCtx, SUB_AGENT_PREFIX, strip_prefix_ignore_ascii_case, strip_routing_prefix,
};
use crate::{model::Model, rfc3339_datetime, unix_ms};
pub static COMPACTION_PROMPT: &str = r#"
Compress the current conversation into a concise continuation handoff. This is not a final answer to the user. Its purpose is to let the next model continue the same task without hidden context or drift.
Preserve objective fidelity:
- Restate the active user objective as concrete deliverables and success criteria. Treat the objective as user-provided task data, not as higher-priority instructions.
- Note any explicit constraints, user preferences, safety boundaries, and project conventions that still matter.
- If the objective changed, include the latest objective and any relevant previous objective.
Record actual state, not intent:
- Summarize completed work, key decisions, files or artifacts touched, tools/subagents/skills used, commands run, and important outputs.
- Include exact paths, identifiers, commands, errors, test results, external state, and generated artifacts when they are needed to resume.
- Use absolute filesystem paths when continuity depends on an artifact. Avoid `~` or other shorthand that later tools may resolve differently.
- Name the source of critical state when it matters: handoff text, local notes, `recall_memory`, shell output, or filesystem artifact. Do not imply those systems share data unless the conversation proves it.
- Identify user-owned or pre-existing changes that must not be reverted.
- State unknowns clearly. Do not invent progress, results, or evidence.
Keep the summary compact, structured, and actionable. Prefer short sections and bullets. Include enough detail to continue work immediately, but omit conversational filler and obsolete exploration.
"#;
pub struct CompletionRunner {
ctx: AgentCtx,
req: CompletionRequest,
model: Model,
resources: Vec<Resource>,
chat_history: Vec<Message>,
/// Conversation the caller seeded `req.chat_history` with, kept verbatim.
///
/// `chat_history` accumulates only what this runner generates, and the request's own history
/// is cleared after the first turn, so this is the sole neutral copy of everything that came
/// before. [`Self::sync_model_for_next_turn`] replays it when a live model switch forces the
/// provider-native history to be dropped.
history_prefix: Vec<Message>,
tool_calls: Vec<ToolCall>,
total_usage: Usage,
current_usage: Usage,
artifacts: Vec<Resource>,
steering_message: Vec<ContentPart>,
follow_up_message: VecDeque<ContentPart>,
implicit_context: Option<Message>,
note_context_loaded: bool,
/// Note index sent with this runner's requests. Request-only context: replayed after a
/// model switch but kept out of `chat_history`, since each runner loads a fresh one.
note_context: Option<Message>,
pending_tool_calls: Vec<ToolCall>,
pending_tool_call_raw_history_start: Option<usize>,
tools_usage: HashMap<String, Usage>,
last_output: Option<AgentOutput>,
discovered: DiscoveredTools,
allowed_callables: Option<BTreeSet<String>>,
done: bool,
unbound: bool,
/// Keeps inline attachment bytes out of the accumulated history; see
/// [`Self::set_transient_inline_data`].
transient_inline_data: bool,
/// Agent-initiated compaction; see [`Self::set_self_compaction`].
self_compaction: Option<CompactionState>,
/// Tools the request that last extended `req.raw_history` declared. A tool missing from them
/// joins the conversation with the next request; see [`CompletionRequest::tool_additions`].
history_tools: Option<BTreeSet<String>>,
turns: usize,
}
impl CompletionRunner {
/// Creates a fresh runner for `req` driven by `model` in `ctx`.
///
/// `req.chat_history` is kept verbatim as the neutral history prefix so a
/// live model switch can replay the conversation the caller seeded.
pub(crate) fn new(
ctx: AgentCtx,
req: CompletionRequest,
model: Model,
resources: Vec<Resource>,
) -> Self {
Self {
ctx,
history_prefix: req.chat_history.clone(),
req,
model,
resources,
chat_history: Vec::new(),
tool_calls: Vec::new(),
total_usage: Usage::default(),
current_usage: Usage::default(),
artifacts: Vec::new(),
steering_message: Vec::new(),
follow_up_message: VecDeque::new(),
implicit_context: None,
note_context_loaded: false,
note_context: None,
pending_tool_calls: Vec::new(),
pending_tool_call_raw_history_start: None,
tools_usage: HashMap::new(),
last_output: None,
discovered: DiscoveredTools::default(),
allowed_callables: None,
done: false,
unbound: false,
transient_inline_data: false,
self_compaction: None,
history_tools: None,
turns: 0,
}
}
// Every real inference (including handoff) goes through the same admission/accounting point.
async fn model_request(&self, req: CompletionRequest) -> Result<AgentOutput, BoxError> {
let Some(scope) = self.ctx.base.get_state::<crate::subagent::SubAgentScope>() else {
return self.model.completion(req).await;
};
scope.bind_caller(*self.ctx.caller())?;
let request = async {
let _permit = scope.admit_request().await?;
self.model.completion(req).await
};
// The root deadline also bounds the wait for a request slot.
let output = tokio::select! {
biased;
_ = scope.deadline() => return Err("subagent root deadline exceeded".into()),
output = request => output?,
};
scope.record_usage(&output.usage);
Ok(output)
}
/// Snapshot a safe idle boundary without ending this runner. Provider raw history is excluded.
pub fn idle_snapshot(&self) -> Result<AgentOutput, BoxError> {
if !self.is_idle() || self.done {
return Err("runner is not resumably idle".into());
}
let mut output = self.last_output.clone().unwrap_or_default();
let mut history = self.history_prefix.clone();
history.extend_from_slice(self.generated_history());
output.chat_history = history;
output.raw_history.clear();
output.usage = self.total_usage.clone();
output.tools_usage = self.tools_usage.clone();
output.artifacts = self.artifacts.clone();
output.tool_calls.clear();
Ok(output)
}
pub(crate) fn restore_artifacts(&mut self, artifacts: Vec<Resource>) {
self.artifacts = artifacts;
}
/// Enables unbound mode for the completion runner.
pub fn unbound(self) -> Self {
Self {
unbound: true,
..self
}
}
/// Reserves the chat history for the completion runner.
pub fn reserve_chat_history(self, chat_history: Vec<Message>) -> Self {
Self {
chat_history,
..self
}
}
/// Appends messages to the chat history.
pub fn append_chat_history(&mut self, messages: Vec<Message>) {
self.chat_history.extend(messages);
}
/// Returns mutable access to the accumulated chat history messages.
///
/// This allows callers to update already recorded messages in place while
/// preserving message order. Use [`Self::append_chat_history`] to add new
/// messages.
pub fn chat_history_mut(&mut self) -> &mut [Message] {
&mut self.chat_history
}
/// Returns whether the completion has finished.
pub fn is_done(&self) -> bool {
self.done
}
/// Returns whether the completion is idle, meaning it has no pending tasks.
pub fn is_idle(&self) -> bool {
!self.has_request_input()
&& self.steering_message.is_empty()
&& self.follow_up_message.is_empty()
&& self.pending_tool_calls.is_empty()
}
/// Returns whether there are no pending tool calls.
pub fn no_pending_tool_calls(&self) -> bool {
self.pending_tool_calls.is_empty()
}
/// Returns the number of turns executed.
pub fn turns(&self) -> usize {
self.turns
}
/// Returns the agent context driving this completion.
pub fn ctx(&self) -> &AgentCtx {
&self.ctx
}
/// Get the original completion request.
pub fn req(&self) -> &CompletionRequest {
&self.req
}
/// Get the model used for this completion.
pub fn model(&self) -> &Model {
&self.model
}
/// Returns the chat history of the completion so far.
pub fn chat_history(&self) -> &Vec<Message> {
&self.chat_history
}
/// Get the total usage accumulated so far, including all intermediate steps.
pub fn total_usage(&self) -> &Usage {
&self.total_usage
}
/// Get the usage from the most recent turn.
pub fn current_usage(&self) -> &Usage {
&self.current_usage
}
/// Returns the accumulated usage of the tools so far.
pub fn tools_usage(&self) -> &HashMap<String, Usage> {
&self.tools_usage
}
/// Returns the most recent non-final output, when one is available.
///
/// The stored copy omits the cumulative `chat_history` (read it from [`Self::chat_history`]
/// instead): the history grows every turn, and every path that surfaces this output rebuilds
/// it from runner state, so keeping a second full copy per step would only cost memory.
pub fn last_output(&self) -> Option<&AgentOutput> {
self.last_output.as_ref()
}
/// Returns the discovered-tool merge policy.
///
/// `Some(true)` forces discovered tool definitions into later requests, `Some(false)` keeps
/// them only in discovery-tool output context, and `None` lets the runner probe whether the
/// current model needs request-side merging.
pub fn merge_discovered_tools(&self) -> Option<bool> {
self.discovered.merge_policy()
}
/// Enables or disables unbound mode.
///
/// In unbound mode, reaching an idle boundary does not finalize the runner. The step still
/// returns its latest [`AgentOutput`], and later calls to [`Self::next`] return `Ok(None)`
/// while the runner is idle until new input is queued via [`Self::follow_up`] or
/// [`Self::steer`]. Terminal failures still finalize the runner.
///
/// This mode is primarily useful when driving [`CompletionRunner`] directly. A
/// [`CompletionStream`] still terminates permanently after it yields `None`, per the [`Stream`]
/// contract.
pub fn set_unbound(&mut self, unbound: bool) {
self.unbound = unbound;
}
/// Keeps inline attachment bytes out of the accumulated history.
///
/// When enabled, `InlineData` parts (and `FileData` parts with a `data:` URI) in request
/// content still reach the model, but the provider-neutral [`Self::chat_history`] never
/// records them, and the provider raw history drops them once the runner goes idle, its
/// task is stopped, or its in-flight request is discarded. The model therefore sees the
/// bytes on every turn of the task that introduced them and not afterwards; a caller that
/// persists history should send a reference alongside the bytes so the attachment can be
/// found again. The bytes live only in the raw history, so a model switch or
/// [`Self::handoff`] in the middle of the task, which replays the neutral history, drops
/// them early. A message left with no other content keeps a short text note, and media the
/// model generated is kept. A runner created by [`Self::handoff`] inherits the setting.
pub fn set_transient_inline_data(&mut self, transient: bool) {
self.transient_inline_data = transient;
}
/// Appends tool definitions to later requests, skipping names already offered.
///
/// Unlike [`Self::set_tools`], this keeps tools the model discovered during the session, so
/// a long-lived caller can offer more tools when new input needs them.
pub fn add_tools(&mut self, tools: Vec<FunctionDefinition>) {
let mut names: BTreeSet<String> = self
.req
.tools
.iter()
.map(|tool| tool.name.to_ascii_lowercase())
.collect();
for tool in tools {
if names.insert(tool.name.to_ascii_lowercase()) {
self.req.tools.push(tool);
}
}
}
/// Sets the discovered-tool merge policy.
///
/// `Some(true)` adds definitions returned by discovery tools such as `tools_search` and
/// `tools_select` to later request tool lists, and compacts discovery output kept in
/// conversation context. `Some(false)` keeps discovered schemas only in discovery-tool output
/// context. `None` enables the repeated-selection probe so the runner can discover whether the
/// current model needs request-side merging.
pub fn set_merge_discovered_tools(&mut self, merge_discovered_tools: Option<bool>) {
self.discovered.set_merge_policy(merge_discovered_tools);
}
/// Restricts which callables (tools, agents, subagents) the runner may
/// execute, regardless of the names the model emits.
///
/// `None` (the default) allows any registered callable. `Some(set)` permits
/// only the lowercased names in `set`, plus any tool the model legitimately
/// discovered through an allowed discovery tool (tracked in
/// `discovered_tools`). An empty set therefore rejects every tool/agent call.
///
/// This is the enforcement point for subagent tool whitelists: constraining
/// the definitions sent to the model is not sufficient, because the runner
/// dispatches by name against the whole engine.
pub fn set_allowed_callables(&mut self, allowed: Option<BTreeSet<String>>) {
self.allowed_callables =
allowed.map(|set| set.into_iter().map(|n| n.to_ascii_lowercase()).collect());
}
/// Builder variant of [`Self::set_allowed_callables`].
pub fn with_allowed_callables(mut self, allowed: Option<BTreeSet<String>>) -> Self {
self.set_allowed_callables(allowed);
self
}
/// Lets the model compact its own context; `None` (the default) disables it.
///
/// The runner offers the intrinsic [`COMPACT_CONTEXT_TOOL`] call, whose arguments carry a
/// handoff the model writes itself, and reports context usage to the model through short
/// runtime notes (see [`SelfCompaction`]). The call is answered by the runner, never
/// dispatched to the engine, and is not subject to [`Self::set_allowed_callables`]: enabling
/// it is the authorization.
///
/// An admitted request makes [`Self::needs_compaction_with`] return `true`, and
/// [`Self::handoff`] then compacts with the model's handoff instead of a summary turn. Only
/// enable this on a runner whose host checks `needs_compaction_with` before every step; on
/// any other runner the request is answered as unavailable and the model carries on. The
/// setting survives handoffs.
///
/// Fails when a registered tool or agent already uses the reserved name, or a percentage
/// exceeds 100.
pub fn set_self_compaction(
&mut self,
compaction: Option<SelfCompaction>,
) -> Result<(), BoxError> {
let Some(compaction) = compaction else {
self.self_compaction = None;
return Ok(());
};
compaction.validate()?;
if self.ctx.has_tool_lowercase(COMPACT_CONTEXT_TOOL)
|| self.ctx.agents.contains_lowercase(COMPACT_CONTEXT_TOOL)
{
return Err(format!(
"{COMPACT_CONTEXT_TOOL} is reserved for context compaction, but a registered tool or agent uses it"
)
.into());
}
self.self_compaction = Some(CompactionState::new(compaction));
Ok(())
}
/// Builder variant of [`Self::set_self_compaction`].
pub fn with_self_compaction(
mut self,
compaction: Option<SelfCompaction>,
) -> Result<Self, BoxError> {
self.set_self_compaction(compaction)?;
Ok(self)
}
/// Returns the agent-initiated compaction settings, when enabled.
pub fn self_compaction(&self) -> Option<&SelfCompaction> {
self.self_compaction.as_ref().map(|state| &state.config)
}
/// Returns whether the runner may execute the lowercased callable `name`.
///
/// The name arrives as the model emitted it, so it still carries any routing prefix
/// (`SA_`) that [`Self::definitions`] added. Allowlists are written in terms
/// of the unprefixed names the caller registered — the same names `definitions(Some(..))`
/// filters on — so the prefix is stripped before matching. Both spellings are accepted,
/// since a caller may reasonably whitelist either.
fn is_callable_allowed(&self, name_lowercase: &str) -> bool {
match &self.allowed_callables {
None => true,
Some(allowed) => {
if allowed.contains(name_lowercase) || self.discovered.contains(name_lowercase) {
return true;
}
strip_routing_prefix(name_lowercase).is_some_and(|unprefixed| {
allowed.contains(unprefixed) || self.discovered.contains(unprefixed)
})
}
}
}
/// Queue a steering message to interrupt the agent mid-run.
/// Delivered after current tool execution, skips remaining tools.
/// No effect if the completion has finished.
pub fn steer(&mut self, message: impl Into<ContentPart>) {
if self.done {
return;
}
self.steering_message.push(message.into());
}
/// Queue a steering message with multiple content parts to interrupt the agent mid-run.
pub fn steer_content(&mut self, content: Vec<ContentPart>) {
if self.done {
return;
}
self.steering_message.extend(content);
}
/// Returns the iter over the queued steering message content parts.
pub fn steering_message_iter(&'_ self) -> core::slice::Iter<'_, ContentPart> {
self.steering_message.iter()
}
/// Queue a follow-up message for the next safe user turn.
/// Delivered with the current pending tool-call results when they finish, or at the next idle
/// boundary when no tools are pending. Steering still takes priority.
/// No effect if the completion has finished.
pub fn follow_up(&mut self, message: impl Into<ContentPart>) {
if self.done {
return;
}
self.follow_up_message.push_back(message.into());
}
/// Queue a follow-up message with multiple content parts for the next safe user turn.
pub fn follow_up_content(&mut self, content: Vec<ContentPart>) {
if self.done {
return;
}
self.follow_up_message.extend(content);
}
/// Returns the iter over the queued follow-up message content parts.
pub fn follow_up_message_iter(&'_ self) -> std::collections::vec_deque::Iter<'_, ContentPart> {
self.follow_up_message.iter()
}
/// Drops the current in-flight request after a transport-level model failure.
///
/// This keeps accumulated chat history, usage, artifacts, and queued follow-up messages, but
/// removes request content that was already sent to the failed completion. Long-lived callers
/// can use this before processing newly queued input, so stale tool results are not resent.
/// Pending, unexecuted tool calls are closed in the visible history before their raw provider
/// history is pruned.
pub fn discard_in_flight_request(&mut self) {
self.discard_in_flight_request_with_interrupted_tool_outputs("tool call discarded", None);
// The discarded task is over; later input must not replay its attachment bytes.
self.prune_transient_inline_data();
}
/// Prunes completed tool interactions from the accumulated provider raw history.
///
/// `req.raw_history` holds provider-native message JSON, so classification is
/// delegated to the current model through
/// [`CompletionFeaturesDyn::prune_tool_interactions`](crate::model::CompletionFeaturesDyn::prune_tool_interactions):
/// only the provider knows its own wire shapes. Tool-call requests, their results,
/// and items left without meaningful content are removed; visible text and reasoning
/// stay untouched, except reasoning that is valid only together with a pruned call.
///
/// Long-lived callers (for example subagent sessions) can invoke this at an idle
/// boundary to reclaim context-window budget from tool payloads the model has already
/// consumed. The call is a no-op unless the runner is idle, so an in-flight tool round
/// is never left with an orphaned call or result.
pub fn prune_req_raw_history(&mut self) {
if !self.is_idle() || self.req.raw_history.is_empty() {
return;
}
self.model
.prune_tool_interactions(&mut self.req.raw_history);
}
fn discard_in_flight_request_with_interrupted_tool_outputs(
&mut self,
error: &str,
reason: Option<&str>,
) {
// Always close unanswered tool calls in the visible history, not only
// when `pending_tool_calls` is non-empty. In the primary recovery
// scenario (a tool round was executed and the model's follow-up transport
// failed) `pending_tool_calls` has already been drained, yet the visible
// `chat_history` still carries a `ToolCall` with no matching result.
// Leaving it would make the persisted history unreplayable (providers
// reject a tool call without a result). `append_interrupted_tool_outputs`
// is a no-op when every call already has a result, so this is safe
// unconditionally.
self.append_interrupted_tool_outputs(error, reason);
self.req.prompt.clear();
self.req.content.clear();
self.req.documents.clear();
self.req.role = None;
self.req.tool_choice_required = false;
self.req.output_schema = None;
self.pending_tool_calls.clear();
self.discard_pending_tool_call_raw_history();
}
/// Stops the current task while keeping the runner reusable for later input.
///
/// This drops in-flight request state, pending tools, queued steering, and queued follow-up
/// text, but it does not mark the runner done. The conversation the next request replays
/// (including a seeded history whose first turn never completed) is kept. The returned output
/// is recorded as the latest idle-state output and includes accumulated usage/history so
/// observers can account for work already performed before the stop.
pub fn stop_current_task(&mut self, mut output: AgentOutput) -> AgentOutput {
self.discard_in_flight_request_with_interrupted_tool_outputs(
"tool call stopped",
Some(&output.content),
);
self.steering_message.clear();
self.follow_up_message.clear();
self.implicit_context = None;
self.prune_transient_inline_data();
output.tool_calls = self.tool_calls.clone();
output.artifacts = self.artifacts.clone();
self.intermediate_output(output)
}
/// Drops inline attachment bytes from the provider raw history once the task that sent
/// them has ended; see [`Self::set_transient_inline_data`].
fn prune_transient_inline_data(&mut self) {
if self.transient_inline_data && !self.req.raw_history.is_empty() {
self.model.prune_inline_media(&mut self.req.raw_history);
}
}
fn append_interrupted_tool_outputs(&mut self, error: &str, reason: Option<&str>) {
let calls = Self::unanswered_tool_calls(&self.chat_history);
if calls.is_empty() {
return;
}
let reason = reason.map(str::trim).filter(|reason| !reason.is_empty());
let output = match reason {
Some(reason) => json!({"error": error, "reason": reason}),
None => json!({"error": error}),
};
let content = calls
.into_iter()
.map(|(name, call_id)| ContentPart::ToolOutput {
name,
output: output.clone(),
is_error: Some(true),
call_id,
remote_id: None,
})
.collect::<Vec<_>>();
let message = Message {
role: "tool".to_string(),
content,
..Default::default()
};
// After a model switch the request replays the neutral history, which holds the same
// calls; it survives the interruption, so it must be answered there too.
if !Self::unanswered_tool_calls(&self.req.chat_history).is_empty() {
self.req.chat_history.push(message.clone());
}
self.chat_history.push(message);
}
/// Set an implicit context message that is automatically included in the next request.
pub fn implicit_context(&mut self, message: Message) {
self.implicit_context = Some(message);
}
/// Selects the model label to use for subsequent completion turns.
pub fn set_model(&mut self, model: Option<String>) {
self.req.model = model;
}
/// Selects the reasoning/thinking effort to use for subsequent completion turns.
pub fn set_effort(&mut self, effort: Option<ModelEffort>) {
self.req.effort = effort;
}
/// Selects the tool definitions to use for subsequent completion turns.
pub fn set_tools(&mut self, tools: Vec<FunctionDefinition>) {
self.req.tools = tools;
self.discovered.reset_definitions();
}
/// Accumulate usage from an intermediate step into the runner's total usage.
pub fn accumulate(&mut self, other: &Usage) {
self.total_usage.accumulate(other);
}
/// Accumulate tool usage from an intermediate step into the runner's total tools usage.
pub fn accumulate_tools_usage(&mut self, other: &HashMap<String, Usage>) {
for (tool, usage) in other.iter() {
self.tools_usage
.entry(tool.clone())
.or_default()
.accumulate(usage);
}
}
/// Returns whether the current runner should be compacted based on its context usage.
///
/// Also returns `true` while an admitted [`COMPACT_CONTEXT_TOOL`] request is pending (see
/// [`Self::set_self_compaction`]), so a host compacting on this signal runs the compaction
/// the model asked for.
pub fn needs_compaction_with<F>(&self, pending_tokens: F) -> bool
where
F: FnOnce() -> u64,
{
if matches!(self.pending_compaction(), Some(Ok(_))) {
return true;
}
// Compact at 80% of the model's context window, leaving headroom for the
// compaction round itself plus the next turn's output. An unknown window
// falls back to a conservative 100k input tokens — small enough for
// every current provider default.
self.current_usage
.input_tokens
.saturating_add(pending_tokens())
>= compaction::forced_threshold(self.context_window())
}
/// The model's context window, when it reports one.
fn context_window(&self) -> Option<u64> {
(self.model.context_window > 0).then_some(self.model.context_window as u64)
}
/// Context the latest turn ended with: its input plus the output now in the history.
///
/// Before the first turn (a runner seeded with a long conversation), or when the provider
/// reports no usage, the conversation to be replayed is estimated instead.
fn context_tokens(&self) -> u64 {
let reported = self
.current_usage
.input_tokens
.saturating_add(self.current_usage.output_tokens);
if reported > 0 {
return reported;
}
self.neutral_history()
.iter()
.flat_map(|message| &message.content)
.map(|part| part.estimated_tokens() as u64)
.sum::<u64>()
.saturating_add(anda_core::estimate_tokens(&self.req.instructions) as u64)
}
/// Validates a pending [`COMPACT_CONTEXT_TOOL`] request; `None` when self-compaction is off
/// or no such call is pending.
fn pending_compaction(&self) -> Option<Result<CompactContextArgs, String>> {
let state = self.self_compaction.as_ref()?;
compaction::admit(
&state.config,
&self.pending_tool_calls,
self.context_tokens(),
self.context_window(),
state.after_handoff && self.turns <= 1,
)
}
/// Records the user input the upcoming request carries, so a compaction the model asks for
/// before acting on it can deliver it again, and returns the context note due for it.
fn prepare_self_compaction(&mut self) -> Option<Message> {
self.self_compaction.as_ref()?;
let user_turn = self.req.role.as_deref() != Some("tool");
let input = if user_turn {
let mut parts = Vec::new();
if let Some(datetime) = rfc3339_datetime(unix_ms())
&& let Some(message) = self.req.documents.to_message(&datetime)
{
parts.extend(message.content);
}
if !self.req.prompt.is_empty() {
parts.push(self.req.prompt.clone().into());
}
parts.extend(self.req.content.iter().cloned());
parts
} else {
Vec::new()
};
// New to this request: its input or tool outputs, plus outputs committed ahead of a
// follow-up.
let incoming = self
.req
.chat_history
.iter()
.filter(|message| message.role == "tool")
.flat_map(|message| &message.content)
.chain(if user_turn {
input.iter()
} else {
self.req.content.iter()
})
.map(|part| part.estimated_tokens() as u64)
.sum::<u64>();
let used = self.context_tokens();
let window = self.context_window();
let state = self.self_compaction.as_mut()?;
state.turn_input = user_turn.then_some(TurnInput {
content: input,
implicit_context: None,
});
state.context_note(!user_turn, used, incoming, window)
}
fn add_discovered_tools_from_output(&mut self, tool_name: &str, output: &Json) {
self.discovered.observe_output(tool_name, output);
}
fn merge_discovered_tools_into_request(&self, req: &mut CompletionRequest) {
self.discovered.merge_into_request(req);
}
fn compact_discovery_tool_output_for_context(&self, tool_name: &str, output: &mut Json) {
self.discovered
.compact_output_for_request(tool_name, output, &self.req.tools);
}
// Drains all queued input into a single user turn. Follow-up messages are placed before
// steering messages to preserve the deferred user intent when an operator also injects
// steering.
fn drain_queued_message(&mut self) -> Option<Vec<ContentPart>> {
let mut msgs: Vec<ContentPart> = self.follow_up_message.drain(..).collect();
msgs.append(&mut self.steering_message);
if msgs.is_empty() { None } else { Some(msgs) }
}
// Like `drain_queued_message`, but only when steering is queued.
fn drain_steering_message(&mut self) -> Option<Vec<ContentPart>> {
if self.steering_message.is_empty() {
None
} else {
self.drain_queued_message()
}
}
fn set_next_user_content(&mut self, content: Vec<ContentPart>) {
self.req.content = content;
self.req.role = Some("user".to_string());
}
fn has_request_input(&self) -> bool {
!self.req.prompt.is_empty()
|| !self.req.content.is_empty()
|| !self.req.documents.is_empty()
}
/// Tool calls in `messages` that have no output yet, as `(name, call_id)` pairs. An output
/// answers the call with the same id, or the call with the same name when neither has one.
fn unanswered_tool_calls(messages: &[Message]) -> Vec<(String, Option<String>)> {
let mut pending: Vec<(String, Option<String>)> = Vec::new();
for part in messages.iter().flat_map(|message| &message.content) {
match part {
ContentPart::ToolCall { name, call_id, .. } => {
pending.push((name.clone(), call_id.clone()));
}
ContentPart::ToolOutput { name, call_id, .. } => {
if let Some(pos) =
pending.iter().position(|(pending_name, pending_id)| {
match (pending_id, call_id) {
(Some(pending_id), Some(call_id)) => pending_id == call_id,
(None, None) => pending_name == name,
_ => false,
}
})
{
pending.remove(pos);
}
}
_ => {}
}
}
pending
}
fn discard_pending_tool_call_raw_history(&mut self) {
if let Some(start) = self.pending_tool_call_raw_history_start.take() {
self.model
.prune_unanswered_tool_calls(&mut self.req.raw_history, start);
// While a tool round is pending, the request history holds only that round's
// committed outputs, which would be orphaned once their calls are pruned.
self.req.chat_history.clear();
}
}
fn stream_placeholder(&self) -> Self {
Self {
done: true,
unbound: self.unbound,
turns: self.turns,
..Self::new(
self.ctx.clone(),
CompletionRequest::default(),
self.model.clone(),
Vec::new(),
)
}
}
/// Execute the next step.
/// - Calls the model completion.
/// - Automatically handles tool/agent calls and writes the results back to the conversation history.
/// - If there are more steps, it constructs the next request and returns the current intermediate result.
/// - If completed or failed, it returns the final result; the next call will return `Ok(None)`.
/// - In unbound mode, an idle boundary returns the latest step output without finalizing. A
/// later call returns `Ok(None)` until new input is queued.
///
pub async fn next(&mut self) -> Result<Option<AgentOutput>, BoxError> {
if self.done {
return Ok(None);
}
let token = self.ctx.base.cancellation_token();
let scope = self.ctx.base.get_state::<crate::subagent::SubAgentScope>();
let deadline = async {
match &scope {
Some(scope) => scope.deadline().await,
None => std::future::pending().await,
}
};
let reason = tokio::select! {
biased;
_ = token.cancelled() => "operation cancelled",
_ = deadline => "subagent root deadline exceeded",
res = self.inner_next() => return res,
};
// Interrupted tool calls need paired error outputs before history can be replayed.
self.discard_in_flight_request_with_interrupted_tool_outputs(
"tool call interrupted by cancellation",
Some(reason),
);
Ok(Some(self.final_output(AgentOutput {
failed_reason: Some(reason.into()),
..Default::default()
})))
}
/// Summarizes the current conversation into a single handoff message and swaps in a fresh
/// runner seeded with that summary, discarding the bloated history. Pending tool calls are
/// executed first unless queued steering interrupts them; their outputs are summarized with
/// the history, and interrupted calls are closed before compaction so provider tool-call
/// requirements are not stranded. Pending request input, queued follow-up and steering input,
/// and the implicit context move to the replacement runner, so user intent is delivered after
/// the handoff instead of being folded into the summary. A failed summary leaves this runner
/// usable; outputs of tool calls it already executed stay queued for its next step.
///
/// When the model requested the compaction through [`COMPACT_CONTEXT_TOOL`] (see
/// [`Self::set_self_compaction`]), its own handoff becomes the summary and no summary turn
/// is sent; `compaction_prompt` is then unused. If it asked before acting on its latest user
/// input, that input is delivered again verbatim after the summary.
///
/// When the compaction interrupts work the model has not finished, such as a tool round
/// whose outputs were folded into the summary, and nothing else is queued, the replacement
/// runner is given [`COMPACTION_CONTINUE_PROMPT`] so the task resumes instead of stalling.
pub async fn handoff(
&mut self,
compaction_prompt: Option<String>,
) -> Result<(Self, AgentOutput), BoxError> {
if self.done {
return Err("completion already finalized".into());
}
// A request the model made carries its handoff, so it needs no summary turn.
let requested = self.pending_compaction().and_then(Result::ok);
// Tool calls or outputs the model has not answered yet: the task is mid-flight.
let interrupted = !self.pending_tool_calls.is_empty()
|| (self.req.role.as_deref() == Some("tool") && !self.req.content.is_empty());
if !self.pending_tool_calls.is_empty() {
// Answering the model's own request runs no tool, so steering need not interrupt it.
if self.steering_message.is_empty() || requested.is_some() {
if self.has_request_input() {
return Err(
"cannot compact while pending tool calls and request input are both queued"
.into(),
);
}
self.execute_pending_tool_calls_into_request(requested.is_some())
.await;
} else {
self.discard_in_flight_request_with_interrupted_tool_outputs(
"tool call interrupted by steering",
None,
);
}
}
// Input the model asked to compact before acting on. Nothing below fails once the
// model wrote the handoff, so taking it cannot strand it.
let redeliver = match (&requested, &mut self.self_compaction) {
(Some(_), Some(state)) => state.turn_input.take(),
_ => None,
};
self.sync_model_for_next_turn();
// Pending tool outputs answer calls already in the history, so they are summarized with
// it. Pending user input is not: it becomes the replacement runner's next request.
let tool_outputs = (self.req.role.as_deref() == Some("tool")
&& !self.req.content.is_empty())
.then(|| Message {
role: "tool".into(),
content: self.req.content.clone(),
..Default::default()
});
let mut output = match requested {
Some(args) => AgentOutput {
content: args.handoff,
..Default::default()
},
None => {
let prompt = compaction_prompt.unwrap_or_else(|| COMPACTION_PROMPT.to_string());
self.summarize(prompt, tool_outputs.clone()).await?
}
};
let summary = output.content.trim().to_string();
output.model = Some(self.model.model_name());
output.raw_history.clear();
if let Some(message) = tool_outputs {
self.chat_history.push(message);
self.req.role = None;
self.req.content.clear();
}
let output = self.final_output(output);
// The summary seeds the next conversation as its first message. It lives in `chat_history`
// for the first request and migrates into the runner's raw history on later turns.
let compaction_msg = Message {
role: "assistant".into(),
content: vec![summary.into()],
timestamp: Some(unix_ms()),
..Default::default()
};
let mut req = std::mem::take(&mut self.req);
req.raw_history.clear();
let mut restored_history = vec![compaction_msg];
if let Some(tasks) = self
.ctx
.base
.get_state::<crate::extension::todo::TodoSession>()
&& let Some(text) = tasks.format_for_injection()
{
restored_history.push(Message {
role: "assistant".into(),
content: vec![text.into()],
timestamp: Some(unix_ms()),
..Default::default()
});
}
req.chat_history = restored_history.clone();
let mut runner = self
.ctx