-
Notifications
You must be signed in to change notification settings - Fork 73
Expand file tree
/
Copy pathmain.py
More file actions
2845 lines (2594 loc) · 135 KB
/
Copy pathmain.py
File metadata and controls
2845 lines (2594 loc) · 135 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
"""Agent loop: the core processing engine."""
from __future__ import annotations
import asyncio
import json
import re
import time
from contextlib import AsyncExitStack, aclosing
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
from raven.agent.context import ContextBuilder
from raven.agent.loop.recovery import (
POST_TOOL_NUDGE,
RecoveryAction,
RecoveryLimits,
classify_empty_response,
)
from raven.agent.subagent import SubagentManager
from raven.agent.tools.ask_user import AskUserTool
from raven.agent.tools.deep_research import (
DeepResearchManager,
DeepResearchOfferTool,
DeepResearchTool,
deep_research_mode,
)
from raven.agent.tools.file_search import FindTool, GrepTool
from raven.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from raven.agent.tools.media_gen import (
ImageGenerateTool,
MusicGenerateTool,
SpeechGenerateTool,
VideoGenerateTool,
)
from raven.agent.tools.message import MessageTool
from raven.agent.tools.registry import ToolRegistry
from raven.agent.tools.shell import ExecTool
from raven.agent.tools.spawn import SpawnTool
from raven.agent.tools.web import WebFetchTool, WebSearchTool
from raven.memory_engine.base import TokenBudget
from raven.memory_engine.consolidate.consolidator import MemoryConsolidator, MemoryStore
from raven.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from raven.providers.capabilities import image_placeholder_text, supports_image_tool_result
from raven.sandbox import SandboxConfig, SandboxExecutor, SandboxInitError, build_executor
from raven.session.manager import Session, SessionManager
from raven.spine.turn import Origin
from raven.token_wise.pricing import resolve_context_window
from raven.tracing import semconv, trace
from raven.utils.helpers import estimate_prompt_tokens, is_image_part, is_inline_image
_ABORTED_ACTION_REPLY = (
"The operation was not completed, and no alternative method will be attempted. "
"Would you like me to continue with the remaining parts of the task that do not "
"require this operation?"
)
# NOTE: ``raven.context_engine`` is intentionally imported lazily (inside
# ``__init__`` and ``_assemble_context_messages``) to break a runtime
# import cycle: ``raven.agent.__init__`` eagerly loads AgentLoop,
# while ``raven.context_engine.curator`` imports ``ContextBuilder`` from
# ``raven.agent.context`` — a module-level top-down ``from
# raven.context_engine import ...`` here re-enters a partially-initialized
# package and raises ImportError on ``TurnContext``.
if TYPE_CHECKING:
from raven.agent.hook import CompositeHook
from raven.agent.tools.base import Tool
from raven.config.raven import (
ContextConfig,
MemoryConfig,
RuntimeConfig,
SkillForgeRouterConfig,
)
from raven.config.schema import ChannelsConfig, DeepResearchToolConfig, ExecToolConfig
from raven.context_engine import ContextEngine
from raven.memory_engine.backend import MemoryBackend
from raven.proactive_engine.schedulers.cron.service import CronService
from raven.routing.router import ModelRouter
from raven.sandbox.debug_server import SandboxDebugServer
from raven.skill_hub import SkillHubClient
from raven.spine.runner import Drain, Emit, TurnOutcome
from raven.spine.turn import TurnRequest
from raven.token_wise.base import UsageSnapshot
from raven.token_wise.registry import StrategyRegistry
from raven.tui_rpc.question_broker import QuestionBroker
@dataclass
class TurnOutcome:
"""Result of one ``_run_agent_loop`` turn beyond its text reply.
``status`` distinguishes a normal completion from a max-iteration
interruption or an LLM error — so the caller never mistakes "ran out of
budget" for "done" (Bug2 / decision B). ``checkpoint_id`` and
``edited_files`` carry the shadow-git snapshot info used to build the
next turn's recovery prompt.
"""
status: str = "completed" # "completed" | "interrupted" | "error"
checkpoint_id: str | None = None
edited_files: list[str] = field(default_factory=list)
def _filter_qualified_ids(
ids: list[str] | None,
source_prefix: str,
) -> list[str]:
"""FB-1 helper: extract native ids from a list of qualified ids
matching ``<source_prefix>/<native>``.
Returns the bare native portion for each match (i.e. strips the
``"<source>/"`` prefix) so the receiving backend doesn't have to
re-parse. Non-matching / unprefixed / malformed entries silently
drop. ``None`` and empty inputs return ``[]``.
"""
if not ids:
return []
needle = f"{source_prefix}/"
out: list[str] = []
for qid in ids:
if not isinstance(qid, str):
continue
if qid.startswith(needle):
native = qid[len(needle) :]
if native:
out.append(native)
return out
# Asks the model for a best-effort wrap-up after the iteration budget is spent.
# Tools are withheld on this call, so the prompt must not invite another tool
# use or a question — there is no further turn to answer it.
_MAX_ITER_SYNTHESIS_PROMPT = (
"You've used up the tool-calling budget for this turn, so no tools are "
"available now. Using only what you've already gathered, give your best "
"final answer: summarize what you accomplished, deliver any partial "
"results, and briefly note what's left undone. Do not ask questions — "
"there is no further turn to answer them. Reply in the same language as "
"the user's request (this instruction is in English, but it is not the "
"conversation language)."
)
# Returned only if the synthesis call itself fails — never leave the turn silent.
_MAX_ITER_STATIC_FALLBACK = (
"I reached the maximum number of tool call iterations ({n}) without "
"completing the task. You can try breaking the task into smaller steps."
)
# Origins whose turns skip the user-inbound hooks (engagement / decision): a turn
# from one of these is not genuine user input. cron/heartbeat are deliberately
# NOT here: they use real channels and fire the hooks today (run_turn keeps that;
# whether they should is a separate question, not this change). Named for what it
# does, not "proactive" — cron and heartbeat are proactive yet absent, and
# subagent is reactive yet present.
_SKIP_USER_INBOUND_ORIGINS = frozenset({Origin.SENTINEL, Origin.SUBAGENT})
# Origins whose reply skips the ``after_send`` chain (Sentinel NudgeInjector /
# response_modifier): their output is system-originated and must not get a nudge
# layered on. A separate set from _SKIP_USER_INBOUND_ORIGINS on purpose, even
# though the members coincide today — the two gates have different meanings, so
# a future change to one set must not silently move the other (e.g. adding
# cron/heartbeat to the user-inbound set for engagement reasons must not start
# dropping their after_send). SENTINEL = the supersede notice (a system notice);
# SUBAGENT = the result re-injection (skipped so the announce gets no nudge).
_SKIP_AFTER_SEND_ORIGINS = frozenset({Origin.SENTINEL, Origin.SUBAGENT})
# Failure markers a plain retry would likely clear — these must NOT count toward
# the tool-failure-loop streak (nudging on a 429 that self-heals is just noise).
_TRANSIENT_FAILURE_MARKERS = (
"429",
"rate limit",
"timed out",
"timeout",
"no healthy upstream",
"502",
"503",
)
# Successful-but-empty results: the tool ran fine and just found nothing. A
# repeated empty search is legitimate exploration, not a stuck dead call, so it
# must NOT count toward the failure streak.
_EMPTY_SUCCESS_MARKERS = ("no matches found", "no files found")
# Marks the synthetic user message that carries images a transport cannot put in
# a tool result. Not persisted: the tool result above it already names the file
# path, so the only thing this message would add to the transcript is a user turn
# saying "[image]" that the user never sent -- misleading on resume and in
# session export. Deliberately a different key from ``_recovery_synthetic``:
# that one marks empty-response recovery scaffolding, and collapsing the two
# would make either meaning impossible to reason about separately.
_ATTACHED_IMAGE_KEY = "_attached_image"
def _strip_inline_images(content: list[Any]) -> list[Any]:
"""Replace inline base64 images with a text placeholder, for persistence.
Images live for exactly the turn that produced them. Keeping the bytes would
bloat the session JSONL by megabytes per picture, and every later turn would
replay them to the model — paying for an image nobody asked about again.
A *new* list is returned: the input is the live message the model is still
working from this turn, and `_save_turn` only shallow-copies the entry, so
mutating in place would pull the picture out from under the current request.
"""
out: list[Any] = []
for part in content:
if not isinstance(part, dict):
out.append(part)
continue
if is_inline_image(part):
out.append({"type": "text", "text": "[image]"})
else:
out.append(part)
return out
def _is_hard_tool_failure(result: object) -> bool:
"""True for a deterministic tool failure (recurs on an identical retry).
False for success or a transient/retryable error. Used to decide whether a
repeated identical tool call is a stuck loop worth breaking.
"""
s = str(result)
low = s.lower()
if any(m in low for m in _TRANSIENT_FAILURE_MARKERS):
return False
if s.strip().rstrip(".").lower() in _EMPTY_SUCCESS_MARKERS:
return False
m = re.search(r"Exit code:\s*(-?\d+)", s)
if m:
return m.group(1) != "0"
# Real not-found failures (file / dir / path / old_text) all start with
# "Error:" or carry a non-zero exit code, so those are already covered; a
# bare "not found" scan would only risk flagging successful output that
# merely mentions the phrase.
return s.lstrip().startswith("Error") or "error:" in low[:80]
def _loop_break_nudge(tool: str, n: int) -> str:
"""Injected when the same tool fails deterministically N times running, so
the model stops repeating a dead approach instead of adapting."""
return (
f"[loop] `{tool}` has failed {n} times in a row with the same kind of error. "
"Stop repeating it. If it is an external dependency (network/API/search), "
"complete what you can offline from local data and report what stayed blocked. "
"If it is a file or path error, re-examine the EXACT path before any retry — "
"do not call it again unchanged. Otherwise change approach: a different tool, "
"command, or strategy."
)
class AgentLoop:
"""
The agent loop is the core processing engine.
It:
1. Receives messages from the spine
2. Builds context with history, memory, skills
3. Calls the LLM
4. Executes tool calls
5. Sends responses back
"""
_TOOL_RESULT_MAX_CHARS = 16_000
# Max emergency context shrinks per turn before a context overflow is fatal.
_MAX_COMPRESS_RETRIES = 2
# Max image demotions per turn. One is enough: a refusal is deterministic for
# the model, and the first retry also caches the verdict, so a second attempt
# would mean the failure was never about images.
_MAX_IMAGE_DEMOTE_RETRIES = 1
# Most recent tool results kept intact when emergency-shrinking; older ones
# are elided (their bodies are the bulk of mid-turn context growth).
_SHRINK_KEEP_RECENT_TOOL_RESULTS = 3
# Image-bearing messages kept intact when emergency-shrinking. Tighter than
# the tool-result count because one image can cost 1568 tokens: the picture
# the model is currently reasoning about is worth keeping, older ones are the
# cheapest thing to give up.
_SHRINK_KEEP_RECENT_IMAGES = 1
# Tool-failure-loop break: nudge after the same tool fails deterministically
# this many times running; cap the nudges per turn so it can't itself loop.
_LOOP_BREAK_THRESHOLD = 2
_LOOP_BREAK_MAX = 2
def __init__(
self,
provider: LLMProvider,
workspace: Path,
model: str | None = None,
max_iterations: int = 40,
context_window_tokens: int = 65_536,
brave_api_key: str | None = None,
web_proxy: str | None = None,
exec_config: ExecToolConfig | None = None,
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
sandbox_config: SandboxConfig | None = None,
channels_config: ChannelsConfig | None = None,
router: "ModelRouter | None" = None,
strategies: "StrategyRegistry | None" = None,
skill_forge_config: Any = None,
response_modifier: Callable[[str, str], str] | None = None,
on_user_inbound: Callable[["TurnRequest"], None] | None = None,
decision_consumer: "Callable[[TurnRequest], Awaitable[Any]] | None" = None,
hooks: "CompositeHook | None" = None,
now_fn: Callable | None = None,
context_config: "ContextConfig | None" = None,
runtime_config: "RuntimeConfig | None" = None,
interactive: bool = True,
jina_api_key: str | None = None,
max_concurrent_subagents: int = 4,
max_subagent_spawns_per_hour: int = 30,
media_config: Any = None,
deep_research_config: Any = None,
disabled_tools: list[str] | None = None,
tool_search_config: Any = None,
# AG-1: optional plugin-provided MemoryBackend. When supplied,
# the after-turn pipeline gains a third peer step ``backend.store``
# (alongside the existing ``maybe_consolidate`` and the implicit
# ``append_history`` inside session save). ``None`` preserves
# legacy behavior — no plugin-side memory indexing happens.
backend: "MemoryBackend | None" = None,
# Forwarded to ``build_context_engine`` so the factory can
# assemble the unified engine's SkillForgeRouter (Local + Mass +
# Everos) and EverOS recall lane. Both default to ``None``; with
# no backend the engine degrades (recall → [], router Local-only).
memory_config: "MemoryConfig | None" = None,
skill_forge_router_config: "SkillForgeRouterConfig | None" = None,
# Tools contributed by activated plugins (built by the CLI via
# ``build_plugin_tools``). Registered alongside the built-in tools
# in ``_register_default_tools``. ``None`` / empty = no plugin
# tools, default behavior unchanged.
plugin_tools: "list[Tool] | None" = None,
empty_recovery: RecoveryLimits | None = None,
):
from raven.agent.hook import (
CompositeHook,
DecisionConsumerAdapter,
OnUserInboundAdapter,
ResponseModifierAdapter,
)
from raven.config.schema import ExecToolConfig
from raven.token_wise.registry import StrategyRegistry
# Optional transform applied to the final assistant content right
# before outbound delivery. Signature: (session_key, content) -> content.
# Used by Sentinel's NudgeInjector to piggyback on the agent's reply,
# but designed as a generic hook (citations, warnings, etc.).
# Skipped for SENTINEL-origin turns so Sentinel-initiated messages don't
# trigger another layer of inject.
self.response_modifier = response_modifier
# Optional callback fired at the start of _process_message for
# genuinely user-originated inbounds (not Sentinel-origin). Used by
# Sentinel to detect engagement with a recent nudge (accept/dismiss).
# Exception-safe — a raising callback is logged and swallowed.
self.on_user_inbound = on_user_inbound
# Optional async hook fired BEFORE slash-command parsing + normal
# processing. Used by Sentinel's DecisionConsumer to short-circuit
# the agent loop when the user replies to a discovery menu (Phase 4).
# Returning a reply means "I handled this; don't process further".
# Returning None means "fall through to normal flow".
self.decision_consumer = decision_consumer
self.channels_config = channels_config
self.provider = provider
self.workspace = workspace
self.model = model or provider.get_default_model()
# Resolved lazily on the first tool result that carries an image. Keyed
# by model, not a single flag: the loop is a long-lived singleton and
# takes a per-call model (strategies rewrite it, and the model chain
# falls back), so one model's verdict must not answer for another's.
self._image_tool_result_ok: dict[str, bool] = {}
self.max_iterations = max_iterations
# Empty-response recovery budgets. None → enabled defaults.
self._recovery_limits = empty_recovery if empty_recovery is not None else RecoveryLimits()
self.context_window_tokens = context_window_tokens
self.brave_api_key = brave_api_key
self.jina_api_key = jina_api_key
self.web_proxy = web_proxy
from raven.config.schema import DeepResearchToolConfig, MediaGenConfig
self.media_config = media_config or MediaGenConfig()
self.deep_research_config = deep_research_config or DeepResearchToolConfig()
self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace
# TokenWise strategies — empty registry acts as pure pass-through.
self.strategies = strategies if strategies is not None else StrategyRegistry([])
# Fake-clock injection point for benchmark/sim harnesses. Defaults
# to wall clock so production paths (gateway, REPL) are unaffected.
# Used both here (session entry timestamps) and threaded into
# ContextBuilder so the LLM's "Current Time:" prompt stays in
# sync with what we record on persisted messages.
self._now_fn = now_fn or datetime.now
# AG-1: optional plugin-provided MemoryBackend.
# Bootstrap wires this from ``PluginRegistry.build_memory_backend``;
# legacy callsites pass ``None`` and retain the existing post-turn
# pipeline unchanged. See ``_dispatch_backend_store`` for the call
# site that consumes it.
self.backend: "MemoryBackend | None" = backend
# Tools contributed by activated plugins; registered into the
# ToolRegistry by ``_register_default_tools``.
self.plugin_tools: "list[Tool]" = list(plugin_tools or [])
# Phase A: per-turn stash for ``injected_skill_ids`` surfaced by
# :class:`DefaultContextEngine.assemble`'s ``AssembledContext.metadata``.
# Populated inside ``_assemble_context_messages`` so the after-turn
# feedback dispatcher can read it without re-running selection.
# ``None`` means "use the legacy ``_collect_injected_skill_ids``
# path" — see that method for the branch.
self._last_injected_skill_ids: list[str] | None = None
self.context = ContextBuilder(
workspace,
skill_forge_config=skill_forge_config,
llm_provider=provider,
now_fn=now_fn,
)
self.sessions = session_manager or SessionManager(workspace)
# Tool names to omit from the registry — applied after default-tool
# registration and after MCP connect so it can blacklist either group.
# Used by eval harnesses (e.g. BCP) that need a strict tool subset.
self._disabled_tools = set(disabled_tools or [])
self._tool_search_config = tool_search_config
self.tools = ToolRegistry()
# Context engine — the single ContextAssembler.
# Constructed here (after self.tools) so the factory can capture
# ``self.tools.get_definitions`` as a deferred callable; the actual
# tool registry contents are filled by ``_register_default_tools``
# later in this constructor.
#
# Deferred ``raven.context_engine`` import: see module-level note about
# the import cycle with ``raven.agent.__init__``.
if context_config is None:
from raven.config.raven import ContextConfig
context_config = ContextConfig()
from raven.context_engine import build_context_engine
self.context_config = context_config
# Skill Hub client — built once and shared by the HubSkillSource
# (catalog discovery) and the read_skill / use_skill tools (body /
# bundle), so both lanes use one connection pool + identical config.
# ``cache_dir`` points into the workspace skill tree so a use_skill'd
# Hub skill is registry-discoverable on later turns. ``None`` when no
# Hub endpoint is configured — read_skill is then not registered and
# use_skill serves local/everos only.
self._skill_hub_client = self._build_skill_hub_client(
workspace,
skill_forge_router_config,
)
self.context_engine: "ContextEngine" = build_context_engine(
workspace=workspace,
config=context_config,
builder=self.context,
provider=provider,
model=self.model,
context_window_tokens=context_window_tokens,
get_tool_definitions=self.tools.get_definitions,
now_fn=now_fn,
# The factory uses these to assemble the unified engine's
# SkillForgeRouter + EverOS recall lane.
backend=backend,
memory_config=memory_config,
skill_forge_router_config=skill_forge_router_config,
skill_forge_config=skill_forge_config,
skill_hub_client=self._skill_hub_client,
)
# Runtime discipline (5th pillar). Bug2 uses ``runtime.checkpoint``;
# gated by (policy, interactive) — see ``_checkpoint_active``. When
# the gate is closed the loop is byte-identical to baseline.
if runtime_config is None:
from raven.config.raven import RuntimeConfig
runtime_config = RuntimeConfig()
self.runtime_config = runtime_config
self.interactive = interactive
self._checkpoint = None
if self._checkpoint_active(runtime_config.checkpoint.policy, interactive):
from raven.agent.loop.checkpoint import CheckpointService
try:
self._checkpoint = CheckpointService(
workspace,
shadow_dir=runtime_config.checkpoint.shadow_dir,
)
except ValueError as exc:
# Bad shadow_dir (e.g. ``../escape`` or absolute path) →
# CheckpointService refuses to construct. Don't crash the
# whole agent over a config typo; log and disable the
# safety net so the turn still runs.
logger.warning("runtime.checkpoint disabled — {}", exc)
# session_key -> {"checkpoint_id", "files"} stashed when a turn is
# interrupted (max-iter); consumed by the next turn's recovery prompt.
self._pending_recovery: dict[str, dict] = {}
self._sandbox_config = sandbox_config
self._owned_ids: set[str] = set()
self.subagents = SubagentManager(
provider=provider,
workspace=workspace,
model=self.model,
brave_api_key=brave_api_key,
jina_api_key=jina_api_key,
web_proxy=web_proxy,
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
sandbox_config=sandbox_config,
owned_ids=self._owned_ids,
max_concurrent=max_concurrent_subagents,
max_spawns_per_hour=max_subagent_spawns_per_hour,
)
# Executor: synchronous construction only; VM starts in _start_executor()
self._executor: SandboxExecutor = build_executor(sandbox_config, workspace, self._owned_ids)
self._executor_stack: AsyncExitStack | None = None
self._executor_started: bool = False
self._executor_start_lock = asyncio.Lock()
self._debug_server: SandboxDebugServer | None = None
self.router = router
self.enable_personalization = False # Set via configure_personalization()
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._mcp_connecting = False
self._processing_lock = asyncio.Lock()
# Fired after every dispatched turn (success, error, or cancel).
# Used by the proactive-engine WakeScheduler to re-fire wakes that
# were parked while the agent was busy. Callbacks must be cheap and
# must not raise.
self.on_turn_complete: list[Callable[[], None]] = []
self.memory_consolidator = MemoryConsolidator(
workspace=workspace,
provider=provider,
model=self.model,
sessions=self.sessions,
context_window_tokens=context_window_tokens,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
now_fn=now_fn,
)
self._consolidation_tasks: set[asyncio.Task] = set()
# Phase B-3: the L4 facade (``DefaultMemoryEngine`` /
# ``MemoryEngine`` ABC) has been retired. AgentLoop now holds
# the underlying subsystems directly:
#
# - ``self.memory_consolidator`` (above) — markdown compaction
# policy. Owns the ``MemoryStore`` it built; reach it via
# ``self.memory_consolidator.store`` when needed.
# - ``self.context.skills`` — :class:`LocalSkillCatalog` for the
# always-skills + ``# Skills`` render path. The SkillForgeRouter stack
# (assembled in ``context_engine.factory``) owns retrieval.
# AgentHook lifecycle chain. The 3 legacy callback
# parameters (``on_user_inbound`` / ``decision_consumer`` /
# ``response_modifier``) get auto-wrapped into adapter hooks
# and merged with any caller-supplied ``hooks`` composite.
#
# Ordering rationale:
# 1. OnUserInboundAdapter first — pure observer, never
# short-circuits. Keeps FeedbackTracker engagement counting
# every legitimate inbound (matching legacy behavior).
# 2. DecisionConsumerAdapter next — may short-circuit when the
# user replies to a Sentinel TaskDiscovery menu. Observers
# have already fired.
# 3. Caller-supplied ``hooks`` after — typically empty today;
# eval_engine will populate it.
# 4. ResponseModifierAdapter last — only meaningful in
# ``after_send`` phase, where it's the sole writer.
self.hooks: "CompositeHook" = CompositeHook()
if on_user_inbound is not None:
self.hooks.append(OnUserInboundAdapter(on_user_inbound))
if decision_consumer is not None:
self.hooks.append(DecisionConsumerAdapter(decision_consumer))
if hooks is not None:
self.hooks.extend(hooks)
if response_modifier is not None:
self.hooks.append(ResponseModifierAdapter(response_modifier))
self._register_default_tools()
self._apply_disabled_tools()
def _apply_disabled_tools(self) -> None:
"""Unregister tools whose names appear in ``tools.disabled_tools``.
Run after :meth:`_register_default_tools` (here) and after MCP connect
(see :meth:`_connect_mcp`) so the blacklist can cover either group.
Silent on misses — eval configs commonly carry an over-broad list
that's a no-op for tools that weren't registered in this build.
"""
if not self._disabled_tools:
return
for name in list(self._disabled_tools):
if self.tools.has(name):
self.tools.unregister(name)
def configure_personalization(self, enable: bool) -> None:
"""Global switch for the 4-step personalization flow (PAHF-inspired).
When enabled, each message goes through:
Step 1 - classify: classify() — does this request need a preference question?
Step 2 - pre-action interaction: ask one question if needed, extract and store the answer
Step 3 - execute: normal agent loop (unchanged)
Step 4 - post-action learn: post_learn() runs in background after every response
Disabled by default. Enable via config: agents.defaults.enable_personalization: true
"""
self.enable_personalization = enable
logger.info("Personalization flow: {}", "enabled" if enable else "disabled")
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None
for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool, GrepTool, FindTool):
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(
ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
path_append=self.exec_config.path_append,
executor=self._executor,
extra_deny_patterns=self.exec_config.extra_deny_patterns,
)
)
self.tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy))
self.tools.register(WebFetchTool(api_key=self.jina_api_key, proxy=self.web_proxy))
# Media tools (image/speech/video) are opt-in: a tool is registered only
# when the user configured it (a model or apiKey under tools.media.<tool>),
# which Config.effective_media_config() surfaces as a resolved key/model.
# An OpenRouter key set for chat alone never enables them.
media = self.media_config
media_tools = (
(ImageGenerateTool, media.image),
(SpeechGenerateTool, media.speech),
(VideoGenerateTool, media.video),
(MusicGenerateTool, media.music),
)
for cls, tool_cfg in media_tools:
if tool_cfg.api_key or tool_cfg.model:
self.tools.register(
cls(
tool_cfg,
workspace=self.workspace,
proxy=media.proxy,
output_subdir=media.output_subdir,
)
)
# Deep research (MiroThinker) is a paid, minute-scale HTTP engine, so it is
# never a plain default tool. Two modes: ``real`` (key configured) is the
# working tool + async manager; ``offer`` (no key) is a same-named stand-in
# that, on a research query, asks the user deep-vs-regular and guides setup.
self.deep_research_manager: DeepResearchManager | None = None
# The async-delivery submit handle (gateway-wired, post-construction). Kept
# on the loop so a manager built later by promotion inherits it too, rather
# than only the startup manager -- see ``set_deep_research_submit``.
self._deep_research_submit: Callable[[Any], Any] | None = None
# The deep-vs-regular ask broker (transport-wired, post-construction). Kept
# on the loop for the same reason: a tool built later by promotion must
# inherit it, else it silently skips the ask -- see ``set_deep_research_broker``.
self._deep_research_broker: QuestionBroker | None = None
if deep_research_mode(self.deep_research_config) == "real":
self._register_real_deep_research(self.deep_research_config)
else:
self.tools.register(DeepResearchOfferTool())
self.tools.register(MessageTool())
self.tools.register(SpawnTool(manager=self.subagents))
# The QuestionBroker is a per-transport singleton, late-bound via
# set_broker once the transport (TUI RPC server / gateway hub) exists.
self.tools.register(AskUserTool())
if self.cron_service:
# Lazy import: CronTool lives under raven.proactive_engine.schedulers.cron.tool
# which (a) imports raven.agent.tools.base, triggering raven.agent.__init__,
# which (b) imports this very loop module. Importing at function scope breaks the
# cycle since loop.py is fully loaded by the time _register_default_tools runs.
from raven.proactive_engine.schedulers.cron.tool import CronTool
self.tools.register(CronTool(self.cron_service))
# Plugin-contributed tools (e.g. EverOS's ``understand_media``).
# Registered last so a plugin can override a built-in by name if
# it deliberately contributes the same name; ``_apply_disabled_tools``
# still runs afterward and can strip any of them.
for tool in self.plugin_tools:
self.tools.register(tool)
# Skill Hub retrieval tools. ``use_skill`` is source-agnostic — it
# resolves local/everos skills on disk too — so it registers whenever
# the skill registry is reachable. ``read_skill`` only fetches Hub
# bodies (local/everos bodies already ride in context), so it
# registers only when a Hub endpoint is configured.
skill_registry = getattr(
getattr(self.context, "skills", None),
"registry",
None,
)
if skill_registry is not None or self._skill_hub_client is not None:
from raven.agent.tools.skill_hub import ReadSkillTool, UseSkillTool
self.tools.register(
UseSkillTool(client=self._skill_hub_client, registry=skill_registry),
)
if self._skill_hub_client is not None:
self.tools.register(
ReadSkillTool(
client=self._skill_hub_client,
registry=skill_registry,
),
)
# Progressive tool disclosure. Registered last so the catalog it
# searches covers every built-in/plugin tool above; MCP tools join
# later (registered in ``_connect_mcp``) and the strategy picks them up
# since it re-reads the registry each turn.
cfg = self._tool_search_config
if cfg is not None and cfg.enabled:
from raven.agent.tools.tool_search import (
DEFAULT_ALWAYS_VISIBLE,
ToolCallTool,
ToolSearchController,
ToolSearchStrategy,
ToolSearchTool,
)
always = set(DEFAULT_ALWAYS_VISIBLE) | set(cfg.always_visible)
self.tool_search_controller = ToolSearchController(
self.tools,
always_visible=always,
search_result_limit=cfg.search_result_limit,
)
self.tools.register(ToolSearchTool(self.tool_search_controller))
self.tools.register(ToolCallTool(self.tool_search_controller))
# ``first=True``: filter the tool list before CacheOptimizer marks
# the final tool with ``cache_control`` (else the marked tool may be
# filtered out and the breakpoint lost).
self.strategies.register(
ToolSearchStrategy(
self.tool_search_controller,
compaction_threshold=cfg.compaction_threshold,
),
first=True,
)
@staticmethod
def _build_skill_hub_client(
workspace: Path,
skill_forge_router_config: "SkillForgeRouterConfig | None",
) -> "SkillHubClient | None":
"""Construct the shared Skill Hub client, or ``None`` when no Hub is
configured. Downloads land under ``<workspace>/skills/hub`` so a
use_skill'd bundle is discoverable by the on-disk skill registry."""
hub_cfg = getattr(skill_forge_router_config, "hub", None)
if hub_cfg is None or not getattr(hub_cfg, "endpoint", None):
return None
from raven.skill_hub import SkillHubClient
return SkillHubClient(
hub_cfg.endpoint,
api_key=hub_cfg.api_key,
timeout_s=hub_cfg.timeout_s,
source=hub_cfg.source,
cache_dir=workspace / "skills" / "hub",
)
def _supports_image_tool_result(self, model: str | None = None) -> bool:
"""Cached per model: resolving the LiteLLM target parses the model string,
and this is asked once per tool call that returns an image.
A ``False`` learned from a refused request (see ``should_drop_tool_images``)
is written into the same cache, so a static table that guessed wrong stops
costing a wasted call after the first one.
"""
key = model or self.model
if key not in self._image_tool_result_ok:
spec = None
try:
from raven.providers.registry import find_by_model
spec = find_by_model(key)
except Exception:
pass
self._image_tool_result_ok[key] = supports_image_tool_result(self.provider, key, spec)
logger.debug(
"image-in-tool-result support for {}: {}",
key,
self._image_tool_result_ok[key],
)
return self._image_tool_result_ok[key]
# ── Context engine helpers ──────────────────────────────────────────
def _context_messages_for_session(self, session: Session) -> list[dict[str, Any]]:
"""Return the candidate message view owned by the active context engine.
Curator (``owns_compaction=True``) wants the full append-only log so
it can decide what to archive itself; Legacy wants the post-consolidation
slice to match the pre-Curator behavior exactly.
"""
if self.context_engine.owns_compaction:
return list(session.messages)
return session.get_history(max_messages=0)
def _make_token_budget(self, selected_skills: list[Any] | None = None) -> TokenBudget:
"""Compute a conservative per-turn prompt budget for the active engine."""
reserved_output = int(getattr(getattr(self.provider, "generation", None), "max_tokens", 4096) or 4096)
tool_tokens = estimate_prompt_tokens([], self.tools.get_definitions())
system_prompt = self.context.build_system_prompt(selected_skills)
system_tokens = estimate_prompt_tokens([{"role": "system", "content": system_prompt}])
available_history = max(
0,
self.context_window_tokens - reserved_output - tool_tokens - system_tokens,
)
return TokenBudget(
context_length=self.context_window_tokens,
reserved_output=reserved_output,
reserved_tools=tool_tokens,
reserved_system=system_tokens,
available_history=available_history,
)
def _uses_default_engine(self) -> bool:
"""Whether the active engine owns skill selection via SkillForgeRouter.
Always ``True`` now — there is a single
:class:`ContextAssembler` whose SkillsSegmentBuilder handles
selection and populates ``injected_skill_ids`` in the assembled
metadata. Kept as a method (rather than inlined) because several
callsites still gate on it; it no longer branches on engine name.
"""
return True
async def _select_skills_for_turn(
self,
current_message: str,
history: list[dict],
) -> list[Any] | None:
"""No host-side pre-selection — the engine's SkillForgeRouter owns it.
The unified engine selects + renders skills internally and
surfaces ``injected_skill_ids`` via ``AssembledContext.metadata``,
which AgentLoop reads out of ``_last_injected_skill_ids`` after
assemble. No SkillMeta list flows through this path.
"""
return None
async def _assemble_context_messages(
self,
*,
session: Session,
session_key: str,
current_message: str,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
selected_skills: list[Any] | None = None,
) -> list[dict[str, Any]]:
"""Ask the active context engine for the main-agent message window."""
from raven.context_engine import TurnContext # deferred — see module note
# Phase A / Phase C tidy: reset the metadata stash BEFORE calling
# the engine. If ``engine.assemble`` raises partway, the next
# caller falls back to the legacy ``_collect_injected_skill_ids``
# path rather than accidentally consuming a previous turn's
# injected ids. Only successful assemble repopulates the stash.
self._last_injected_skill_ids = None
session_messages = self._context_messages_for_session(session)
assembled = await self.context_engine.assemble(
session_key,
session_messages,
self._make_token_budget(selected_skills),
turn=TurnContext(
current_message=current_message,
media=media,
channel=channel,
chat_id=chat_id,
selected_skills=selected_skills,
),
)
# Stash the engine's injected_skill_ids so the after-turn
# feedback dispatcher can read the source-qualified ids the
# unified engine populates via SkillForgeRouter. If the key is absent
# the stash stays None and _collect_injected_skill_ids falls back
# to the SkillMeta-based path.
meta_ids = assembled.metadata.get("injected_skill_ids") if assembled.metadata else None
self._last_injected_skill_ids = list(meta_ids) if meta_ids else None
messages = assembled.messages
self._inject_recovery_block(session_key, messages)
return messages
@staticmethod
def _checkpoint_active(policy: str, interactive: bool) -> bool:
"""Resolve ``runtime.checkpoint.policy`` against the call-site's
``interactive`` signal. ``"interactive"`` (the default) skips the
snapshot for one-shot ``-m`` invocations — those have no "next turn"
to inject recovery into, so paying the snapshot cost there is just
deadweight. ``"always"`` opts in regardless; ``"never"`` opts out
regardless."""
if policy == "never":
return False
if policy == "always":
return True
return interactive # policy == "interactive"
def _stash_recovery(self, session_key: str, outcome: "TurnOutcome") -> None:
"""Remember an interrupted turn's snapshot so the next turn in this
session gets a recovery prompt. No-op unless checkpoint is enabled
and the turn was actually interrupted with something to recover.
Status filter is intentional: only ``"interrupted"`` triggers a
recovery prompt. ``"error"`` turns still get a per-turn shadow
commit (useful for audit), but they don't usually have a partial-
edits trajectory to resume (provider 400 etc.) and surfacing
"Files modified last turn" for them would be misleading.
"""
if self._checkpoint is None or outcome.status != "interrupted":
return
if outcome.edited_files or outcome.checkpoint_id:
self._pending_recovery[session_key] = {
"checkpoint_id": outcome.checkpoint_id,
"files": outcome.edited_files,
}
def _inject_recovery_block(self, session_key: str, messages: list[dict]) -> None:
"""Prepend a recovery notice to the current user message when the
previous turn for this session was interrupted. Consumed once on
successful injection; if the current message's content has an
unexpected shape (None / dict / etc.) the pending entry is kept so
a later assembly with a normal content can still inject it."""
recovery = self._pending_recovery.get(session_key)
if not recovery or not messages:
return
last = messages[-1]
if last.get("role") != "user":
# Last message isn't the user turn — keep the recovery pending so
# the next assembly (which does end with the user message) injects it.
return
content = last.get("content")
files = recovery.get("files") or []
cid = recovery.get("checkpoint_id")
lines = ["[Recovery — the previous turn was interrupted before finishing]"]
if files:
lines.append("Files modified last turn: " + ", ".join(files))
if cid:
lines.append(f"Checkpoint: {cid}")
lines.append("Verify the current state of these files before continuing.")
block = "\n".join(lines)
# Mutate first, pop second — atomic from the caller's perspective. If
# we can't safely write to ``content`` (unknown shape) the recovery
# stays pending instead of being silently dropped on the floor.
if isinstance(content, str):
last["content"] = f"{block}\n\n{content}"
elif isinstance(content, list):
last["content"] = [{"type": "text", "text": block}] + content
else:
return # unexpected content shape → keep pending
self._pending_recovery.pop(session_key, None)
@trace.instrument("memory.feedback", extract=semconv.memory_feedback)
async def _dispatch_backend_feedback(
self,
session_key: str,
injected_skill_ids: list[str] | None,
used_skill_ids: list[str] | None = None,
) -> None:
"""FB-1: forward source-qualified skill-usage signals to
:meth:`MemoryBackend.feedback`.
Skill IDs surface with a ``<source>/<native_id>`` prefix
(``local/git-resolver`` / ``mass/abc`` / ``everos/xyz``).
Only the ``everos/`` prefix is forwarded — static libraries
(``local`` / ``mass``) have no feedback channel; the dispatcher
is silent for them (no warning, just skipped). Unprefixed legacy
ids (e.g. raw skill names emitted by the pre-SkillForgeRouter
``SkillService.select`` path) are also skipped — they predate
the qualified-id convention and there's no safe routing target.
No-ops when:
- ``self.backend is None`` (no plugin wired)
- No qualified-id matches the ``everos/`` prefix
- The injected + used lists are both empty / None
Exceptions from :meth:`backend.feedback` are caught + logged.
The host MUST NOT abort the after-turn pipeline because a
plugin's feedback handler raised — feedback is best-effort
telemetry, not load-bearing state.
"""