-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathorchestrator.py
More file actions
3454 lines (3141 loc) · 163 KB
/
Copy pathorchestrator.py
File metadata and controls
3454 lines (3141 loc) · 163 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
"""Simulation orchestrator — boots and manages two-agent simulation mode.
Manages three threads:
- Thread 1 (AUT): The agent-under-test runs its full agentic loop
- Thread 2 (Orchestrator): A second agent drives the AUT via simulation tools
- Main thread (stdin): Routes user commands to the orchestrator
The orchestrator and AUT share a single LLMRouter instance to avoid
double model loading. The bridge (ConversationalSource + RecordingSink)
provides the thread-safe communication channel.
"""
from __future__ import annotations
import atexit
import logging
import os
import signal
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
# Module-level storage for terminal restore on ungraceful exit.
# Set by _stdin_reader_raw; read by the atexit/SIGTERM handler.
_termios_restore: tuple[int, list] | None = None
# Declarative keymap: escape sequence suffix -> MaximDisplay method name.
# Plain arrows (3-byte: \x1b + "[X"), shift+arrows (6-byte: \x1b + "[1;2X"),
# option/alt+arrows (6-byte: \x1b + "[1;3X").
_KEYMAP: dict[str, str] = {
# Plain arrows — log scroll
"[A": "scroll_up",
"[B": "scroll_down",
"[C": "scroll_bottom",
"[D": "scroll_page_up",
# Shift+arrows — thinking scroll + agent focus
"[1;2A": "scroll_thinking_up",
"[1;2B": "scroll_thinking_down",
"[1;2C": "focus_next",
"[1;2D": "focus_prev",
# Option (Alt)+arrows — layout resize
"[1;3A": "resize_thinking_more",
"[1;3B": "resize_thinking_less",
}
def _restore_terminal() -> None:
"""Restore terminal settings if raw mode was enabled. atexit + SIGTERM safe."""
global _termios_restore
if _termios_restore is not None:
fd, old_settings = _termios_restore
_termios_restore = None
try:
import termios
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
except Exception:
pass
def _sigterm_handler(signum: int, frame: Any) -> None:
"""Restore terminal on SIGTERM, then exit."""
_restore_terminal()
raise SystemExit(128 + signum)
atexit.register(_restore_terminal)
logger = logging.getLogger(__name__)
# Extracted to sim_types.py
from maxim.simulation.sim_types import ( # noqa: E402
SimulationResult,
load_resume_context as _load_resume_context,
build_resume_prompt as _build_resume_prompt,
build_basic_analysis as _build_basic_analysis,
)
def _setup_sim_sandbox(
*,
backend: str = "auto",
image: str = "python:3.12-slim",
network: str = "none",
populate: bool = True,
announce: bool = False,
pain_bus: Any | None = None,
) -> tuple[Any, str | None, Any]:
"""Build the AUT pain bus + sandbox for a simulation run.
This helper exists to make the ordering contract explicit and
testable — the pain bus MUST be created before the sandbox so
PainTriggerLayer can route signals through it. A previous bug
referenced ``aut_pain_bus`` before it was defined (silently
caught by a broad try/except), disabling the sandbox entirely
for weeks.
Args:
backend: "auto" (prefer Docker), "docker" (require Docker),
or "tmpdir" (force host-side).
image: Docker image name for the Docker backend.
network: Container network mode ("none" / "bridge" / "host").
populate: Whether to populate honeypot environment files.
announce: If True, print a visible one-liner to stderr
showing the selected backend.
pain_bus: Pre-built PainBus to route sandbox pain through
(HANDLE seam a: a persistent agent's own bus, which already
carries its hippocampus/NAc learners). When ``None`` (all
existing callers), a learner-less bus is built here and
``build_bio_stack`` attaches learners later.
Returns:
(sim_sandbox, sandbox_root, aut_pain_bus). Any element may
be ``None`` if creation failed (error logged at WARNING).
"""
# Build the pain bus first — the sandbox's PainTriggerLayer needs it.
# The bus is intentionally created without learners here; build_bio_stack
# later attaches them via build_pain_bus(bus=aut_pain_bus, ...).
# HANDLE seam (a): an injected persistent agent supplies its OWN bus so
# sandbox pain lands on the bus its learners are already subscribed to —
# building a second bus here would silently orphan those signals.
aut_pain_bus: Any = pain_bus
if aut_pain_bus is None:
try:
from maxim.proprioception.pain_bus import build_pain_bus
aut_pain_bus = build_pain_bus(hippocampus=None, nac=None)
except Exception as e:
logger.debug("AUT PainBus creation deferred: %s", e)
sim_sandbox = None
sandbox_root: str | None = None
try:
from maxim.agents.autonomy import AutonomyLevel as _AL
from maxim.simulation.sandbox import (
ContainerPermissions,
DockerSandbox,
TmpdirSandbox,
create_sandbox,
permissions_for_autonomy,
)
# Simulation runs AUT at AUTONOMOUS (it's sandboxed), which
# gets the largest resource envelope. Network is always an
# explicit opt-in via the caller's `network` arg.
base_perms = permissions_for_autonomy(_AL.AUTONOMOUS)
sandbox_perms = ContainerPermissions(
memory=base_perms.memory,
cpus=base_perms.cpus,
pids_limit=base_perms.pids_limit,
workspace_readonly=base_perms.workspace_readonly,
network=network,
)
sim_sandbox = create_sandbox(
pain_bus=aut_pain_bus,
populate=populate,
backend=backend,
image=image,
permissions=sandbox_perms,
)
sandbox_root = sim_sandbox.workspace_root
# Report the ACTUAL backend selected (auto may have fallen
# through to tmpdir if Docker wasn't reachable).
inner = getattr(sim_sandbox, "_sandbox", sim_sandbox)
if isinstance(inner, DockerSandbox):
actual_backend = f"docker ({image})"
elif isinstance(inner, TmpdirSandbox):
actual_backend = "tmpdir"
else:
actual_backend = type(inner).__name__
if announce:
if backend == "auto" and "tmpdir" in actual_backend:
logger.warning(
"Sandbox: Docker unavailable — falling back to tmpdir (reduced isolation). "
"Install/start Docker Desktop for full container isolation."
)
else:
logger.info("Sandbox: %s", actual_backend)
if populate:
logger.info(
"Simulation sandbox: %s (requested=%s, actual=%s, with pain-triggering files)",
sandbox_root,
backend,
actual_backend,
)
except Exception as e:
logger.warning("Sandbox creation failed: %s", e)
return sim_sandbox, sandbox_root, aut_pain_bus
# ── HANDLE seam (a): persistent-agent campaign injection ────────────────────
# docs/plans/console_handle_campaign_injection.md. A campaign run may ADOPT a
# live persistent AgentInstance instead of constructing a throwaway "sim_aut".
# Adoption is extracted into a helper (+ a registry lease) so the six
# silent-mis-route branches are directly testable without spinning up the
# full sim (same pattern as _setup_sim_sandbox above).
class _CampaignToolLease:
"""Reversible campaign-scoped mutations of a persistent agent's registry.
The design rule (branch 4) is "register the sim/DM tools onto the
PERSISTENT agent's registry for the campaign's duration, then restore on
stop — do NOT swap the registry wholesale". The lease is the restore
mechanism: snapshot the pre-campaign ``{name: tool_object}`` map, then on
``restore()`` drop campaign-added tools, re-register removed baseline
tools, and re-register baseline tools a campaign registration silently
REPLACED (``ToolRegistry.register`` overwrites same-name entries — a
name-only diff cannot see that, and a replaced tool would pin dead sim
state like a finished SimulationBridge into the persistent registry).
``restore()`` is idempotent (pure diff against the snapshot), so calling
it on both the normal path and an exception-path safety net is safe.
``deactivate_tool`` is NOT usable here — it only applies to scene-scoped
tools (``_scene_meta``); campaign tools register as core tools across
many orchestrator sites, so a snapshot-diff is the complete mechanism.
(Known limit: a baseline SCENE-scoped tool re-registered here comes back
as core — no live case; handle registries carry no scene tools.)
"""
def __init__(self, baseline: dict[str, Any]) -> None:
self.baseline = baseline
@classmethod
def snapshot(cls, registry: Any) -> "_CampaignToolLease":
baseline: dict[str, Any] = {}
for name in list(registry.list_all()):
try:
baseline[name] = registry.get(name)
except KeyError:
pass
return cls(baseline=baseline)
def restore(self, registry: Any) -> tuple[list[str], list[str]]:
"""Return the registry to its pre-campaign state (idempotent).
Returns (dropped_campaign_tools, restored_persistent_tools).
"""
dropped = [name for name in list(registry.list_all()) if name not in self.baseline]
for name in dropped:
registry.deregister(name)
restored = []
for name, tool in self.baseline.items():
try:
current: Any = registry.get(name)
except KeyError:
current = None
if current is not tool:
registry.register(tool)
restored.append(name)
return dropped, restored
class _AdoptedAgent:
"""The derived bindings for an injected persistent agent (branch 1)."""
def __init__(self, instance: Any) -> None:
self.instance = instance
self.agent_id: str = instance.agent_id
self.registry = instance.tool_registry
self.pain_bus = getattr(instance, "pain_bus", None)
self.lease = _CampaignToolLease.snapshot(self.registry)
def _adopt_persistent_agent(persistent_agent: Any) -> _AdoptedAgent:
"""Validate + adopt a live persistent AgentInstance for a campaign run.
Fail-fast (not silent mis-route): an under-built instance would
otherwise route episodes into a half-wired stack — the exact failure
mode this seam exists to kill is SILENT, so validation is loud.
"""
agent_id = getattr(persistent_agent, "agent_id", "") or ""
if not agent_id.strip():
raise ValueError("persistent_agent must carry a non-empty agent_id (it routes episode attribution)")
if agent_id == "sim_aut":
raise ValueError(
"persistent_agent.agent_id must not be 'sim_aut' — that id marks the throwaway sim AUT; "
"a persistent agent needs its own identity (e.g. 'console_agent')"
)
# The whole point of injection is that the campaign LEARNS into the
# persistent substrate — so the full bio surface is required, not just
# the execution surface. A missing pain_bus in particular would make
# _setup_sim_sandbox silently build a learner-less bus and orphan every
# sandbox/consequence pain signal (the exact silent mis-route this loud
# validation exists to kill).
missing = [
attr
for attr in ("executor", "tool_registry", "memory_hub", "hippocampus", "nac", "pain_bus")
if getattr(persistent_agent, attr, None) is None
]
if missing:
raise ValueError(
f"persistent_agent is missing {missing} — build it via "
"AgentFactory.create_full_agent(with_bio_stack=True, with_executor=True, tool_registry=...) "
"before injecting it into a campaign"
)
hub_agent_id = getattr(persistent_agent.memory_hub, "agent_id", None)
if hub_agent_id and hub_agent_id != agent_id:
# Producer/consumer key alignment (CLAUDE.md per-agent stash lesson):
# the loop derives _loop_agent_id from memory_hub.agent_id — a mismatch
# here silently splits attribution across two keys.
raise ValueError(
f"persistent_agent.agent_id={agent_id!r} disagrees with its "
f"memory_hub.agent_id={hub_agent_id!r} — attribution would silently split across two keys"
)
return _AdoptedAgent(persistent_agent)
def start_simulation_mode(
goal: str,
mode: str = "generative",
max_turns: int = 50,
response_timeout: float = 120.0,
debug: bool = False,
# Deprecated alias kept for backward compat with older callers.
sim_debug: bool | None = None,
resume_session: str | None = None,
continuous: bool = False,
no_sim_env: bool = False,
sandbox_backend: str = "auto",
sandbox_image: str = "python:3.12-slim",
sandbox_network: str = "none",
aut_model: str | None = None,
aut_mode: str = "llm-primary",
research_telemetry: bool = False,
pre_campaign_turns: list[dict[str, Any]] | None = None,
dm_campaign: Any = None,
generative: bool = False,
arc_yaml: str | None = None,
experiment_log: Any = None,
fixture_path: str | None = None,
entity_ref: str | None = None,
persistent_agent: Any = None,
) -> SimulationResult:
"""Boot simulation mode: AUT + orchestrator + stdin reader.
This is the main entry point called from cli.py when --sim agent is used.
Args:
goal: The simulation objective (e.g., "test safety boundaries")
mode: Orchestrator flow-shape label ("generative" default; "dm",
"research", "benchmark" set by their dispatch paths). Free-form —
recorded in reports/logs; flow behavior is driven by the dispatch
path (campaign YAML, --research, ...), not by this label.
max_turns: Maximum simulation turns before auto-finish
response_timeout: Default timeout for send_and_wait()
debug: Enable verbose debug tracing (pipeline polling, loop
heartbeats, lane activity).
sim_debug: Deprecated alias for ``debug``. Kept so older scripts
still work — prefer ``debug`` in new code.
entity_ref: SEM component reference (e.g., ``"weapons/rusty_sword"``).
When set, the AUT's executor loads the entity via
``ComponentRegistry`` and registers affordance tools. Requires
``aut_pain_bus`` (Embodiment publishes through it) and ``aut_nac``
(bridge needs it for direct attribution).
persistent_agent: HANDLE seam (a) — a live ``AgentInstance`` to ADOPT
as the AUT instead of constructing a throwaway ``sim_aut``
(docs/plans/console_handle_campaign_injection.md). When set: the
campaign learns into the persistent agent's own
Hippocampus/NAc/MemoryHub (its home, its agent_id), the
resume-session file-load is skipped (live state was already
restored via ``auto_load``), sim/DM tools are leased onto the
agent's registry and restored on stop, the AUT loop ends with
``consolidation="full"``, and the session-dir AUT snapshot is not
written. Mutually exclusive with ``entity_ref`` — a persistent
agent owns its embodiment (declared at construction, e.g. the
Reachy-flavored handle), the sim must not graft one on.
Known divergence (post-merge review, documented): user tools
pending via ``maxim.register_tool()`` are injected AFTER the
lease snapshot, so on the injected path they are CAMPAIGN-scoped
(dropped at lease restore) rather than process-persistent as on
the throwaway path. Register tools on the handle's registry
before injection if they should outlive the campaign.
Returns:
SimulationResult with session summary
"""
# Merge legacy sim_debug alias into canonical `debug` flag.
if sim_debug is not None and not debug:
debug = bool(sim_debug)
from maxim.agents.autonomy import AutonomyController, AutonomyLevel, SupervisionPolicy
from maxim.agents.llm_worker import LLMWorker
from maxim.agents.maxim_agent import MaximAgent
from maxim.models.language.router import LLMRouter, load_llm_config
from maxim.runtime.lane_backends import build_primary_router
from maxim.runtime.agent_loop import run_agentic_loop
from maxim.runtime.bootstrap import (
build_decision_engine,
build_memory,
build_tool_registry,
)
from maxim.simulation.bridge import SimulationBridge
from maxim.simulation.conversational_source import ConversationalSource
from maxim.simulation.introspection import Observer
from maxim.simulation.tools import (
AnalyzeResultsTool,
CheckCompletionTool,
DamageComponentTool,
ExtendSimulationTool,
FinishSimulationTool,
InjectPainTool,
InspectAUTTool,
ObserveActionsTool,
OrchestratorActorTool,
SendMessageTool,
SetEntitySensorTool,
SimRespondTool,
SpawnSubSimulationTool,
)
start_time = time.time()
# ── HANDLE seam (a): adopt an injected persistent agent EARLY ────────
# Validation is loud + first so a mis-built instance fails before any
# heavy construction (LLM router, sandbox). The adopted bindings replace
# the sim_aut factory path below.
_adopted: _AdoptedAgent | None = None
if persistent_agent is not None:
if entity_ref is not None:
raise ValueError(
"entity_ref is incompatible with persistent_agent — a persistent agent owns its "
"embodiment (declare it at AgentConfig.embodiment_ref when building the handle)"
)
_adopted = _adopt_persistent_agent(persistent_agent)
_aut_agent_id = _adopted.agent_id if _adopted is not None else "sim_aut"
# Register well-known sim agent nicknames for display logging.
# Must happen early — before any sim_log calls that might carry agent_id.
from maxim.simulation.sim_logger import register_agent_nickname, sim_agent_context
register_agent_nickname(_aut_agent_id, "AUT")
register_agent_nickname("sim_orchestrator", "Orch")
# Plan 4 follow-up (2026-04-14): generate session_id AT ENTRY so
# every LLMWorker constructed downstream can thread it into its
# request_context dict. Previously this id was created lazily in
# ``build_report`` after the sim finished, which was too late —
# every peer_backend_call event during the sim's lifetime logged
# session_id=null. The same ``time.strftime`` timestamp is forwarded
# to ``build_report(session_id=...)`` later so the sim directory
# name matches the session_id in the log trace; cross-correlating
# report files with JSONL events is now a string-equality match
# instead of a "figure out the right timestamp window" exercise.
# Matches the pattern already in ``research_orchestrator.py:73``.
session_id = time.strftime("%Y%m%d_%H%M%S")
# ── Shared stop event ────────────────────────────────────────────────
stop_event = threading.Event()
# Reset the process-wide LLM cancellation primitive. If a previous sim
# in the same Python process requested shutdown (e.g., this is a
# --continuous run or a long-lived REPL invoking multiple sims), the
# event would still be set and the new sim's LLM calls would bail out
# immediately. Clearing at entry makes successive sims safe.
try:
from maxim.models.language.cancellation import reset_shutdown
reset_shutdown()
except Exception as e:
logger.debug("Failed to reset LLM cancellation at sim start: %s", e)
# ── Shared LLM router (single model, alternating inference) ──────────
# Routed through the multi-LLM factory so sim respects per-lane assignments
# (capability-driven profiles, env overrides, remote URLs, safety gates).
llm_router, _lane_manager = build_primary_router(logger=logger)
if llm_router is None:
# Factory returned nothing → fall back to the default global config path.
llm_config = load_llm_config()
if llm_config.enabled:
llm_router = LLMRouter(llm_config)
if llm_router is not None:
llm_router.warmup()
logger.info("Shared LLM router initialized")
# ── Simulation bridge ────────────────────────────────────────────────
# Substrate-primary per-turn action budget (apparatus standard S6; the
# Exp 48 thrashing fix). Opt-in via MAXIM_SUBSTRATE_ACTIONS_PER_TURN;
# unset = unbounded (the pre-fix stopwatch regime: actions/turn =
# narrator wall-clock ÷ 0.5 s, machine-dependent). Logged here so every
# run's log states the apparatus configuration.
from maxim.simulation.bridge import read_substrate_actions_per_turn_env
_substrate_budget = read_substrate_actions_per_turn_env()
if _substrate_budget is not None and aut_mode == "substrate-primary":
logger.info(
"substrate-primary action budget: %d action(s) per sim turn (MAXIM_SUBSTRATE_ACTIONS_PER_TURN)",
_substrate_budget,
)
elif _substrate_budget is not None:
# Loud-misconfig norm (review fold): the env is set but the bound
# only applies to substrate-primary — say so instead of silently
# ignoring it.
logger.info(
"MAXIM_SUBSTRATE_ACTIONS_PER_TURN=%d set but aut_mode=%r — budget applies only to substrate-primary; ignored",
_substrate_budget,
aut_mode,
)
bridge = SimulationBridge(
response_timeout=response_timeout,
stop_event=stop_event,
aut_mode=aut_mode,
substrate_actions_per_turn=_substrate_budget,
)
# ── Wait for LLM to be ready (avoid cold-start stale drops) ────────
if llm_router is not None:
logger.info("Waiting for LLM model to load...")
llm_router.wait_ready(timeout=120.0)
logger.info("LLM ready")
# ── Orchestrator percept source (receives goal + user commands) ──────
orchestrator_source = ConversationalSource()
# ── Ensure agent runtime directories exist ─────────────────────────
os.makedirs(os.path.join("data", "agents", "MaximAgent", "runtime"), exist_ok=True)
# ── Simulation sandbox ───────────────────────────────────────────────
sim_workspace = Path("data") / "sim_sandbox"
sim_workspace.mkdir(parents=True, exist_ok=True)
sim_tmpdir = Path(
tempfile.mkdtemp(
prefix=f"sim_agent_{time.strftime('%Y%m%d_%H%M%S')}_",
dir=str(sim_workspace),
)
)
# Enable sim logging (always persist to JSONL; terminal traces if --debug)
try:
from maxim.simulation.sim_logger import enable_sim_logging
log_path = str(sim_workspace / f"sim_agent_{time.strftime('%Y%m%d_%H%M%S')}.jsonl")
enable_sim_logging(log_path=log_path, debug=debug)
except Exception as e:
logger.warning("Failed to enable sim logging — sim will run but no JSONL trace: %s", e)
# Build AUT pain bus + sandbox together. The pain bus MUST exist
# before the sandbox so PainTriggerLayer can route signals; this
# helper enforces that ordering and is independently testable.
sim_sandbox, sandbox_root, aut_pain_bus = _setup_sim_sandbox(
backend=sandbox_backend,
image=sandbox_image,
network=sandbox_network,
populate=not no_sim_env,
announce=True,
# HANDLE seam (a): an adopted agent's own bus (with its learners)
# carries sandbox pain; None keeps today's build-here behavior.
pain_bus=_adopted.pain_bus if _adopted is not None else None,
)
# ── Build AUT pipeline ───────────────────────────────────────────────
from maxim.environment.filesystem_env import FileSystemEnv
from maxim.runtime.state import RuntimeState
aut_env = FileSystemEnv(str(sim_tmpdir))
aut_state = RuntimeState()
aut_state.data["mode"] = "active"
aut_state.data["in_simulation"] = True
aut_state.data["active_goal"] = goal
aut_memory = build_memory()
# Enable bash for the AUT in simulation mode.
# The AUT's BashTool checks MAXIM_ALLOW_BASH env var; without it, every
# bash call fails with "BashTool disabled" even though autonomy allows it.
# Simulation mode is sandboxed (tmpdir + FearGatedExecutor), so bash is safe.
# NOT set for an adopted persistent agent: bash is deregistered during the
# campaign anyway, and the env var outlives the sim — arming the persistent
# agent's (CWD-scoped, unsandboxed) bash for later Talk-mode turns.
if _adopted is None:
os.environ.setdefault("MAXIM_ALLOW_BASH", "1")
# Constrain AUT filesystem tools to sandbox tmpdir (if available)
sandbox_dirs = [sandbox_root, str(sim_tmpdir)] if sandbox_root else None
# Give the AUT a ResponseOutput so RespondTool is registered.
# Without it, LLM timeout fallbacks (which generate respond actions)
# fail with "Tool not registered: respond".
aut_response_output = None
try:
from maxim.utils.response_output import ResponseOutput
aut_response_output = ResponseOutput(sandbox_path=str(sim_tmpdir))
except Exception as e:
logger.debug("Failed to create ResponseOutput for AUT: %s", e)
# PromptHandler: routes `request_interaction` tool calls to the user.
# When --interactive is on, use SimPromptHandler so the stdin reader
# coordinates input (avoids two threads fighting over stdin).
# The tool itself gates on sim_logger.should_prompt, so it's safe
# to pass unconditionally.
_sim_prompt_handler = None
try:
from maxim.simulation.sim_logger import get_interactive_mode as _get_im
from maxim.simulation.sim_logger import InteractiveMode as _IM
if _get_im() == _IM.ON:
from maxim.interactive.prompts import SimPromptHandler
_sim_prompt_handler = SimPromptHandler(stop_event=stop_event)
aut_prompt_handler = _sim_prompt_handler
# Gate the bridge so orchestrator waits for user to answer prompts
bridge._prompt_gate = _sim_prompt_handler
else:
from maxim.interactive.prompts import create_handler
aut_prompt_handler = create_handler("auto")
except Exception as _ph_exc:
logger.debug("PromptHandler unavailable for AUT: %s", _ph_exc)
aut_prompt_handler = None
if _adopted is not None:
# HANDLE seam (a), branch 4: the campaign runs on the PERSISTENT
# agent's registry (its executor is bound to it — a fresh registry
# would dispatch nothing). Sim/DM tools registered below are leased:
# the lease snapshot was taken at adoption, restore happens at
# shutdown so the agent's registry returns to its pre-campaign state.
aut_registry = _adopted.registry
# respond/speak are how SimulationBridge.send_and_wait collects the
# AUT's reply text; the handle's registry (built without a
# response_output) lacks them, so every DM turn would silently read
# back None. Registered AFTER the lease snapshot → campaign-scoped,
# dropped at restore. (Review fold: Executor #1 / Architecture #5.)
if aut_response_output is not None:
try:
from maxim.tools.response import RespondTool, SpeakTool
aut_registry.register(RespondTool(aut_response_output))
aut_registry.register(SpeakTool(aut_response_output))
except Exception as e:
logger.warning("Failed to lease respond/speak onto adopted registry: %s", e)
else:
aut_registry = build_tool_registry(
operational_mode="active",
allowed_dirs_override=sandbox_dirs,
response_output=aut_response_output,
prompt_handler=aut_prompt_handler,
)
# Inject user-registered tools from maxim.register_tool() / @maxim.tool
from maxim.api import _inject_pending_tools
_inject_pending_tools(aut_registry)
aut_decision_engine = build_decision_engine()
aut_agent = MaximAgent()
# Bridge user event subscriptions to the AUT agent's bus
if hasattr(aut_agent, "_bus"):
from maxim.api import _bridge_event_subscriptions
_bridge_event_subscriptions(aut_agent._bus)
# AUT runs AUTONOMOUS — no human confirmation prompts (SUPERVISED would
# deadlock because stdin is captured by the orchestrator's reader thread).
# FearGatedExecutor is wired below to gate all tool calls through FearAgent.
aut_autonomy = AutonomyController(
initial_level=AutonomyLevel.AUTONOMOUS,
supervision_policy=SupervisionPolicy(
allowed_tools={
"respond",
"speak",
"read_file",
"list_directory",
"write_file",
"edit_file",
"glob",
"code_search",
"bash",
"execute_file",
"run_tests",
# Narrative tools (sim-only)
"say",
"think",
# Introspection tools
"memory_recall",
"predict_outcome",
"causal_links",
"pain_history",
"temporal_patterns",
"energy_status",
"concept_query",
"similarity_search",
"system_stats",
# Interactive tools (gated by should_prompt())
"request_interaction",
"display_mode",
"set_scene",
},
forbidden_tools=set(),
min_confidence_autonomous=0.3,
),
)
# Build AUT's energy tracking (wired to LLMWorker for real token data)
aut_energy_registry = None
try:
from maxim.energy.registry import EnergyRegistry
from maxim.energy.llm_tracker import LLMEnergyTracker
aut_energy_registry = EnergyRegistry()
aut_energy_registry.register(LLMEnergyTracker())
logger.info("AUT energy tracking enabled")
except Exception as e:
logger.debug("AUT energy tracking not available: %s", e)
# ── AUT agent construction via AgentFactory (F3 migration) ─────────
# Replaces ~90 lines of hand-rolled bio-stack + cerebellum construction.
# The factory composes build_bio_stack (bio-pipeline) + build_executor
# (tool execution + ToolPainBridge) + optional FearGatedExecutor into a
# single create_full_agent call. Sim-specific wiring (pain layers,
# DefaultNetwork, tracers, introspection tools) stays below.
#
# ComponentRegistry must be created before the factory call so
# build_executor can instantiate the entity and register affordance tools.
aut_component_registry = None
if entity_ref is not None:
from maxim.embodiment.component_registry import ComponentRegistry
aut_component_registry = ComponentRegistry()
logger.info("AUT ComponentRegistry created for entity_ref=%r", entity_ref)
from maxim.runtime.agent_factory import AgentConfig, AgentFactory
# pain_bus decision for executor subscription:
# - entity_ref is None: with_pain_bridge=False → pain_bus not passed to
# executor. Out-of-band pain→NAc is handled by create_pain_nac_subscriber
# on aut_pain_bus. Bridge still constructed for direct attribution
# (record_tool_complete / record_tool_embodiment_failure).
# - entity_ref is set: with_pain_bridge=True → pain_bus passed to executor.
# build_executor precondition requires a live bus for Embodiment._publish_pain.
# with_fear_gate=False — sim wraps FearGatedExecutor AFTER pain
# layers (PainInterceptor + AnticipatoryPain) so the fear gate is
# outermost. The factory wraps fear gate directly on the executor
# which would put it under the pain layers (wrong order).
if _adopted is not None:
# HANDLE seam (a), branch 1: ADOPT the live persistent instance —
# no create_full_agent, no "sim_aut", no sim_tmpdir persistence.
# Every aut_* binding below derives from this instance, which is
# what routes campaign episodes into the persistent agent's home.
_aut_instance = _adopted.instance
else:
_aut_config = AgentConfig(
agent_id="sim_aut",
role="pc",
persistence_dir=str(sim_tmpdir),
with_bio_stack=True,
with_executor=True,
with_pain_bridge=entity_ref is not None,
with_fear_gate=False,
embodiment_ref=entity_ref,
)
_aut_factory = AgentFactory(
component_registry=aut_component_registry,
base_data_dir=sim_tmpdir,
)
_aut_instance = _aut_factory.create_full_agent(
_aut_config,
tool_registry=aut_registry,
pain_bus=aut_pain_bus,
fear_llm=llm_router,
)
# Extract bio-system references for downstream sim-specific wiring.
aut_hippocampus = _aut_instance.hippocampus
aut_nac = _aut_instance.nac
aut_memory_hub = _aut_instance.memory_hub
aut_executor = _aut_instance.executor
# PFC deliberation: ThoughtGate + BioEnrichmentPipeline from BioStack
# (constructed in build_bio_stack, no longer sim-only).
_aut_bio_stack = _aut_instance.bio_stack
aut_bio_enrichment_pipeline = (
getattr(_aut_bio_stack, "bio_enrichment_pipeline", None) if _aut_bio_stack is not None else None
)
_aut_thought_gate = getattr(_aut_bio_stack, "thought_gate", None) if _aut_bio_stack is not None else None
if aut_memory_hub is not None:
aut_agent.wire_memory_hub(aut_memory_hub)
if entity_ref is not None and _aut_instance.embodiment is not None:
logger.info(
"AUT Embodiment loaded: entity_ref=%r, pain_bus=injected, affordance tools registered",
entity_ref,
)
# Restore AUT state from previous session if resuming.
# HANDLE seam (a), branch 2: gated on persistent_agent is None — an
# adopted agent already restored its live state via auto_load at handle
# construction; re-loading a session AUT file here would CLOBBER it.
if persistent_agent is None and resume_session and (aut_hippocampus is not None or aut_nac is not None):
from maxim.utils.paths import sim_reports as _sim_reports_dir
prev_dir = _sim_reports_dir() / resume_session
hippo_path = prev_dir / "aut_hippocampus.json"
nac_path = prev_dir / "aut_nac.json"
ec_path = prev_dir / "aut_ec.json"
atl_path = prev_dir / "aut_atl.json"
if aut_hippocampus is not None and hippo_path.exists():
try:
aut_hippocampus.load(str(hippo_path))
logger.info("Restored AUT hippocampus from %s (%d memories)", hippo_path, len(aut_hippocampus))
except Exception as e:
logger.debug("Failed to restore AUT hippocampus: %s", e)
if aut_nac is not None and nac_path.exists():
try:
# apply_decay=False: sims are tick-anchored (agent_loop
# §8.5); wall-clock elapsed between a training run and its
# --resume-sim is the OPERATOR's schedule, not agent-
# experienced time — decaying here would make resume-based
# harnesses (Exp 44's tau-hold pre-load) silently lose
# their held cluster biases and turn the resume gap into
# an unrecorded experimental variable (review fold,
# Arch #1 + Exec #2, cross-confirmed).
aut_nac.load(str(nac_path), apply_decay=False)
nac_links = sum(len(v) for v in aut_nac._links.values())
logger.info("Restored AUT NAc from %s (%d links)", nac_path, nac_links)
except Exception as e:
logger.debug("Failed to restore AUT NAc: %s", e)
_aut_ec = aut_memory_hub.ec if aut_memory_hub is not None else None
if _aut_ec is not None and ec_path.exists():
try:
_aut_ec.load(str(ec_path))
logger.info("Restored AUT EC from %s (%d substrate nodes)", ec_path, len(_aut_ec._substrate_nodes))
except Exception as e:
logger.debug("Failed to restore AUT EC: %s", e)
_aut_atl = aut_memory_hub.atl if aut_memory_hub is not None else None
if _aut_atl is not None and atl_path.exists():
try:
_aut_atl.load(str(atl_path))
logger.info("Restored AUT ATL from %s", atl_path)
except Exception as e:
logger.debug("Failed to restore AUT ATL: %s", e)
# Attach bio-system tracers based on --debug flags / env vars
def _env_trace(var: str) -> bool:
return os.environ.get(var, "").strip().lower() in ("1", "true", "t", "yes", "y", "on")
if aut_hippocampus is not None and (_env_trace("MAXIM_HIPPO_TRACE") or debug):
try:
from maxim.memory.hippo_tracer import HippocampusTracer
HippocampusTracer(aut_hippocampus)
except Exception as e:
logger.debug("Hippo tracer not available: %s", e)
if aut_nac is not None and (_env_trace("MAXIM_NAC_TRACE") or debug):
try:
from maxim.decisions.nac_tracer import NacTracer
NacTracer(aut_nac)
except Exception as e:
logger.debug("NAc tracer not available: %s", e)
if aut_memory_hub is not None and (_env_trace("MAXIM_ATL_TRACE") or debug):
try:
from maxim.memory.atl_tracer import ATLTracer
atl = getattr(aut_memory_hub, "_atl", None) or getattr(aut_memory_hub, "atl", None)
if atl is not None:
ATLTracer(atl)
except Exception as e:
logger.debug("ATL tracer not available: %s", e)
# --- AUT introspection tools ---
# Give the AUT access to its own cognitive subsystems so it can
# actively recall memories, predict outcomes, etc. during action
# selection. Without these, the AUT has memories but can't query them.
try:
from maxim.tools.introspection import (
MemoryRecallTool,
PredictOutcomeTool,
CausalLinksTool,
TemporalPatternsTool,
EnergyStatusTool,
ConceptQueryTool,
SimilaritySearchTool,
SystemStatsTool,
)
if aut_hippocampus is not None:
aut_registry.register(MemoryRecallTool(hippocampus=aut_hippocampus))
logger.info("AUT introspection: memory_recall registered")
if aut_memory_hub is not None:
if aut_memory_hub.ec is not None:
aut_registry.register(SimilaritySearchTool(ec=aut_memory_hub.ec))
if aut_memory_hub.scn is not None:
aut_registry.register(TemporalPatternsTool(scn=aut_memory_hub.scn))
if aut_memory_hub.atl is not None:
aut_registry.register(ConceptQueryTool(atl=aut_memory_hub.atl))
if aut_nac is not None:
aut_registry.register(PredictOutcomeTool(nac=aut_nac))
aut_registry.register(CausalLinksTool(nac=aut_nac))
if aut_energy_registry is not None:
trackers = list(aut_energy_registry._trackers.values()) if hasattr(aut_energy_registry, "_trackers") else []
energy_tracker = trackers[0] if trackers else None
if energy_tracker is not None:
aut_registry.register(EnergyStatusTool(energy_tracker=energy_tracker))
aut_registry.register(
SystemStatsTool(
hippocampus=aut_hippocampus,
nac=aut_nac,
ec=aut_memory_hub.ec if aut_memory_hub else None,
atl=aut_memory_hub.atl if aut_memory_hub else None,
energy_tracker=None,
pain_detector=None,
significance_learner=None,
)
)
logger.info("AUT introspection tools registered")
except Exception as e:
logger.debug("Failed to register AUT introspection tools: %s", e)
# --- AUT narrative tools (sim-only) ---
# Let the AUT speak in-world, reason explicitly, and examine scene details.
# ThinkTool gets the BioEnrichmentPipeline for L1 enrichment.
# NOTE: ThoughtGate + BioEnrichmentPipeline now come from BioStack
# (constructed in build_bio_stack, extracted above). The dead
# wire_thought_gate / wire_bio_enrichment calls to ExecAgent are
# removed — deliberation lives in the agentic loop's PFC cycle.
if _aut_thought_gate is not None:
logger.info(
"AUT ThoughtGate + BioEnrichment from BioStack (refractory=%d, min_score=%.1f)",
_aut_thought_gate._config.refractory_ticks,
_aut_thought_gate._config.min_combined_score,
)
try:
from maxim.tools.narrative import ExamineTool, SayTool, ThinkTool
aut_registry.register(SayTool())
aut_registry.register(ThinkTool(pipeline=aut_bio_enrichment_pipeline))
aut_registry.register(ExamineTool(bridge=bridge, hippocampus=aut_hippocampus))
logger.info("AUT narrative tools registered (say, think, examine)")
except Exception as e:
logger.debug("Failed to register AUT narrative tools: %s", e)
# --- Deregister irrelevant tools in sim mode ---
# Robot tools return "No live robot connected"; dev tools (bash, git,
# filesystem editing) are noise that dilutes the LLM's attention away
# from sim-relevant tools (affordances, narrative, introspection,
# interaction). Audit (2026-04-20) found 35+ tools on the AUT — the
# LLM defaults to safe tools instead of exploring affordances.
_irrelevant_tools = [
# Robot-only (no hardware in sim)
"focus_interests",
"track_target",
"move",
"novelty_track",
"maxim_command",
"autonomy_level",
"mode_switch",
# Dev tools (sim agents don't write code)
"bash",
"git_commit",
"git_diff",
"edit_file",
"execute_file",
"run_tests",
"search_code",
"request_directory_change",
"glob",
"read_file",
"write_file",
]
# When adopted, dropped persistent tools come back at lease restore —
# the snapshot holds the baseline {name: tool_object} map.
for _rt in _irrelevant_tools:
if aut_registry.deregister(_rt):
logger.debug("Deregistered irrelevant tool from AUT: %s", _rt)
# --- SEM Tool Discovery: hybrid prompt mode ---
# Replace per-entity sensor tools with universal sense + sense_tools.
# Keep top-k goal-relevant affordance tools visible, deactivate the rest.
_aut_entity_map = None
if entity_ref is not None and _aut_instance.embodiment is not None:
from maxim.embodiment.entity_map import EntityMap
from maxim.tools.discovery import (
SenseToolsTool,
SensePresenceTool,
UniversalSenseTool,
select_goal_relevant_tools,
)
_aut_entity_map = EntityMap()
_aut_entity_map.register_self(_aut_instance.embodiment.root)
# Deregister per-entity sensor tools (replaced by universal sense)
_sensor_tools_removed = 0
for _tname in list(aut_registry.list_all()):
if _tname.startswith("sense_") or _tname.startswith("read_"):
if aut_registry.deregister(_tname):