-
Notifications
You must be signed in to change notification settings - Fork 517
Expand file tree
/
Copy pathagent.py
More file actions
1960 lines (1727 loc) Β· 93.5 KB
/
Copy pathagent.py
File metadata and controls
1960 lines (1727 loc) Β· 93.5 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 core loop β dual backend (Anthropic + OpenAI compatible), streaming,
4-layer compression, plan mode, sub-agents, budget control.
Agent architecture inspired by Claude Code's published design."""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any, Callable, Awaitable
import anthropic
import openai
from .tools import (
tool_definitions,
execute_tool,
check_permission,
CONCURRENCY_SAFE_TOOLS,
get_active_tool_definitions,
ToolDef,
PermissionMode,
_truncate_result,
)
from .memory import (
start_memory_prefetch,
format_memories_for_injection,
MemoryPrefetch,
)
from .autonomy import (
goal_directive,
GOAL_EVALUATOR_SYSTEM,
GOAL_TRANSCRIPT_FRAMING,
goal_judge_user_message,
parse_goal_verdict,
GOAL_MAX_ITERATIONS,
parse_loop_input,
is_daily_wording,
OFFER_CLOUD_THRESHOLD_SECONDS,
SCHEDULE_WAKEUP_TOOL,
clamp_wakeup_delay,
dynamic_loop_directive,
LOOP_MAX_ITERATIONS,
load_auto_mode_rules,
build_classifier_system,
AUTO_MODE_FAST_PATH_TOOLS,
DENIAL_LIMITS,
build_classifier_transcript,
parse_block_verdict,
classifier_user_message,
)
from .ui import (
print_assistant_text,
print_tool_call,
print_tool_result,
print_error,
print_confirmation,
print_divider,
print_cost,
get_model_pricing,
print_retry,
print_info,
print_sub_agent_start,
print_sub_agent_end,
start_spinner,
stop_spinner,
)
from .session import save_session
from .prompt import build_system_prompt, build_static_system_prompt, build_dynamic_system_context, build_user_context_reminder, load_claude_md
from .subagent import get_sub_agent_config
from .mcp_client import McpManager
# βββ Retry with exponential backoff ββββββββββββββββββββββββββ
def _is_retryable(error: Exception) -> bool:
status = getattr(error, "status_code", None) or getattr(error, "status", None)
if status in (429, 503, 529):
return True
msg = str(error)
if "overloaded" in msg or "ECONNRESET" in msg or "ETIMEDOUT" in msg:
return True
return False
async def _with_retry(fn, max_retries: int = 3):
for attempt in range(max_retries + 1):
try:
return await fn()
except Exception as error:
if attempt >= max_retries or not _is_retryable(error):
raise
delay = min(1000 * (2 ** attempt), 30000) / 1000 + (hash(str(time.time())) % 1000) / 1000
status = getattr(error, "status_code", None) or getattr(error, "status", None)
reason = f"HTTP {status}" if status else (getattr(error, "code", None) or "network error")
print_retry(attempt + 1, max_retries, reason)
await asyncio.sleep(delay)
# βββ Model context windows ββββββββββββββββββββββββββββββββββ
MODEL_CONTEXT = {
"claude-opus-4-6": 200000,
"claude-sonnet-4-6": 200000,
"claude-sonnet-4-20250514": 200000,
"claude-haiku-4-5-20251001": 200000,
"claude-opus-4-20250514": 200000,
"gpt-4o": 128000,
"gpt-4o-mini": 128000,
"MiniMax-M3": 1000000,
"MiniMax-M2.7": 204800,
}
def _get_context_window(model: str) -> int:
return MODEL_CONTEXT.get(model, 200000)
# βββ Thinking support detection βββββββββββββββββββββββββββββ
def _model_supports_thinking(model: str) -> bool:
m = model.lower()
if "claude-3-" in m or "3-5-" in m or "3-7-" in m:
return False
if "claude" in m and any(x in m for x in ("opus", "sonnet", "haiku")):
return True
# MiniMax-M3 (adaptive/off) and MiniMax-M2.7 (always-on) both expose thinking.
if "minimax" in m:
return True
return False
def _model_supports_adaptive_thinking(model: str) -> bool:
m = model.lower()
# MiniMax-M2.7 keeps thinking always-on, so only MiniMax-M3 is adaptive.
return "opus-4-6" in m or "sonnet-4-6" in m or "minimax-m3" in m
def _get_max_output_tokens(model: str) -> int:
m = model.lower()
if "opus-4-6" in m:
return 64000
if "sonnet-4-6" in m:
return 32000
if any(x in m for x in ("opus-4", "sonnet-4", "haiku-4")):
return 32000
return 16384
# βββ Convert tools to OpenAI format βββββββββββββββββββββββββ
def _to_openai_tools(tools: list[ToolDef]) -> list[dict]:
return [
{
"type": "function",
"function": {
"name": t["name"],
"description": t["description"],
"parameters": t["input_schema"],
},
}
for t in tools
]
# βββ Multi-tier compression constants ββββββββββββββββββββββββ
SNIPPABLE_TOOLS = {"read_file", "grep_search", "list_files", "run_shell"}
SNIP_PLACEHOLDER = "[Content snipped - re-read if needed]"
SNIP_THRESHOLD = 0.60
# Above this utilization we snip even while the cache is hot: preserving the
# cache is not worth risking a context overflow. Below it, a hot cache is left
# untouched (see snip functions). Sits between SNIP_THRESHOLD and autocompact.
SNIP_HOT_OVERRIDE = 0.75
MICROCOMPACT_IDLE_S = 5 * 60 # 5 minutes
KEEP_RECENT_RESULTS = 3
# βββ Agent βββββββββββββββββββββββββββββββββββββββββββββββββββ
class Agent:
def __init__(
self,
*,
permission_mode: str = "default",
model: str = "claude-opus-4-6",
api_base: str | None = None,
anthropic_base_url: str | None = None,
api_key: str | None = None,
thinking: bool = False,
max_cost_usd: float | None = None,
max_turns: int | None = None,
confirm_fn: Callable[[str], Awaitable[bool]] | None = None,
custom_system_prompt: str | None = None,
custom_tools: list[ToolDef] | None = None,
is_sub_agent: bool = False,
):
self.permission_mode = permission_mode
self.thinking = thinking
self.model = model
self.use_openai = bool(api_base)
self.is_sub_agent = is_sub_agent
self.tools = custom_tools or tool_definitions
self.max_cost_usd = max_cost_usd
self.max_turns = max_turns
self.confirm_fn = confirm_fn
self.effective_window = _get_context_window(model) - 20000
self.session_id = uuid.uuid4().hex[:8]
self.session_start_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
self.total_input_tokens = 0
self.total_output_tokens = 0
self.total_cache_read_tokens = 0 # Prompt-cache hits (billed ~0.1x)
self.total_cache_creation_tokens = 0 # Prompt-cache writes (billed ~1.25x)
self.last_input_token_count = 0
self.current_turns = 0
self.last_api_call_time = 0.0
# /goal β session-scoped Stop-hook condition, pursued across turns
self.active_goal: dict | None = None
self.goal_stop = False # set on interrupt to break out of goal pursuit
# /loop dynamic mode β set when the model calls schedule_wakeup during a
# tick, read (and cleared) by the loop driver after the turn converges.
self.pending_wakeup: dict | None = None
self.loop_stop = False # set on interrupt to break out of a running loop
# schedule_wakeup is routed to the internal executor only while a dynamic
# loop is active, so it can't shadow a same-named tool or be reached out
# of band.
self.schedule_wakeup_enabled = False
# Auto Mode β transcript-classifier denial tracking (DENIAL_LIMITS).
self.auto_consecutive_denials = 0
self.auto_total_denials = 0
# Abort support
self._aborted = False
self._current_task: asyncio.Task | None = None
# Permission whitelist
self._confirmed_paths: set[str] = set()
# Plan mode state
self._pre_plan_mode: str | None = None
self._plan_file_path: str | None = None
self._plan_approval_fn: Callable[[str], Awaitable[dict]] | None = None
self._context_cleared: bool = False # Set when plan approval clears context
# Thinking mode
self._thinking_mode = self._resolve_thinking_mode()
# Output buffer (sub-agents capture output)
self._output_buffer: list[str] | None = None
# Read-before-edit: track file read timestamps (absolutePath β mtime)
self._read_file_state: dict[str, float] = {}
# MCP integration
self._mcp_manager = McpManager()
self._mcp_initialized = False
# Memory recall state β semantic prefetch per user turn. The handle
# lives on the instance so a recall that settles after this turn's
# last API call is carried over and injected next turn (issue #7).
self._already_surfaced_memories: set[str] = set()
self._session_memory_bytes = 0
self._memory_prefetch: MemoryPrefetch | None = None
# Separate message histories
self._anthropic_messages: list[dict] = []
self._openai_messages: list[dict] = []
# Build system prompt with a static/dynamic split for prefix caching.
# A custom system prompt overrides both halves (all of it is treated as
# static). Otherwise the static core is cacheable, env/git/skills form
# the dynamic tail, and CLAUDE.md + date go into the FIRST user message
# as a <system-reminder> (Claude Code's prependUserContext) β see chat.
# Keeping project-specific content out of the system maximizes sharing.
self._user_context_reminder = ""
if custom_system_prompt:
self._static_system_prompt = custom_system_prompt
self._dynamic_system_context = ""
else:
self._static_system_prompt = build_static_system_prompt()
self._dynamic_system_context = build_dynamic_system_context()
self._user_context_reminder = build_user_context_reminder()
self._base_system_prompt = (
self._static_system_prompt + "\n\n" + self._dynamic_system_context
if self._dynamic_system_context else self._static_system_prompt
)
if self.permission_mode == "plan":
self._plan_file_path = self._generate_plan_file_path()
self._system_prompt = self._base_system_prompt + self._build_plan_mode_prompt()
else:
self._system_prompt = self._base_system_prompt
# Optional: cap the SDK's own retry layer (default 2). Set
# MINI_CLAUDE_SDK_MAX_RETRIES=0 to isolate our _with_retry in tests.
_sdk_retries: dict[str, Any] = {}
_rv = os.environ.get("MINI_CLAUDE_SDK_MAX_RETRIES")
if _rv is not None and _rv != "":
try:
_sdk_retries["max_retries"] = int(_rv)
except ValueError:
pass
# Initialize clients
if self.use_openai:
self._openai_client = openai.AsyncOpenAI(base_url=api_base, api_key=api_key, **_sdk_retries)
self._anthropic_client = None
self._openai_messages.append({"role": "system", "content": self._system_prompt})
else:
kwargs: dict[str, Any] = {}
if api_key:
kwargs["api_key"] = api_key
if anthropic_base_url:
kwargs["base_url"] = anthropic_base_url
kwargs.update(_sdk_retries)
self._anthropic_client = anthropic.AsyncAnthropic(**kwargs)
self._openai_client = None
# βββ Prefix caching (Anthropic) βββββββββββββββββββββββββββββ
def _build_anthropic_system(self) -> list[dict]:
"""Build the `system` field as text blocks with a cache_control
breakpoint on the static core. Everything up to and including that block
(the tool schemas render before `system`, so they are covered too) is
cached server-side; the dynamic tail sits after the breakpoint. This is
Claude Code's scope-omitted path. See how-claude-code-works ch3.6."""
plan_suffix = self._build_plan_mode_prompt() if self.permission_mode == "plan" else ""
dynamic_text = (self._dynamic_system_context + plan_suffix).strip()
blocks: list[dict] = [
{"type": "text", "text": self._static_system_prompt, "cache_control": {"type": "ephemeral"}}
]
if dynamic_text:
blocks.append({"type": "text", "text": dynamic_text})
return blocks
def _with_cache_breakpoints(self, messages: list[dict]) -> list[dict]:
"""Return a COPY of the message list with a cache_control breakpoint on
the last message's final content block, so every prior turn stays in the
cached prefix and only the newest messages are processed. Pure: the
persistent history is never mutated with this API metadata (Claude Code
clones request params at the render layer for the same reason, keeping
session save / compact / restore clean). Faithful to CC's
assistantMessageToMessageParam, we look only at the very LAST block and
skip it when it is a thinking block (unstable content β hurts cache
hits). Only 1 message breakpoint + 1 system breakpoint per request."""
if not messages:
return messages
out = list(messages)
last = out[-1]
raw = last.get("content")
content = [{"type": "text", "text": raw}] if isinstance(raw, str) else list(raw)
tail = content[-1] if content else None
if isinstance(tail, dict) and tail.get("type") not in ("thinking", "redacted_thinking"):
content[-1] = {**tail, "cache_control": {"type": "ephemeral"}}
out[-1] = {**last, "content": content}
return out
def _resolve_thinking_mode(self) -> str:
if not self.thinking:
return "disabled"
if not _model_supports_thinking(self.model):
return "disabled"
if _model_supports_adaptive_thinking(self.model):
return "adaptive"
return "enabled"
@property
def is_processing(self) -> bool:
return self._current_task is not None and not self._current_task.done()
def _build_side_query(self):
"""Build a sideQuery callable for memory recall and the Auto Mode
classifier, works with both backends. temperature=0 for a deterministic
decision β the same input should always yield the same verdict (Claude
Code runs the classifier at temperature 0)."""
if self._anthropic_client:
client = self._anthropic_client
model = self.model
async def _sq(system: str, user_message: str) -> str:
resp = await client.messages.create(
model=model, max_tokens=256, system=system, temperature=0,
messages=[{"role": "user", "content": user_message}],
)
return "".join(b.text for b in resp.content if b.type == "text")
return _sq
if self._openai_client:
client = self._openai_client
model = self.model
async def _sq_oai(system: str, user_message: str) -> str:
resp = await client.chat.completions.create(
model=model, max_tokens=256, temperature=0,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user_message},
],
)
return resp.choices[0].message.content or "" if resp.choices else ""
return _sq_oai
return None
def abort(self) -> None:
self._aborted = True
if self._current_task and not self._current_task.done():
self._current_task.cancel()
def set_confirm_fn(self, fn: Callable[[str], Awaitable[bool]]) -> None:
self.confirm_fn = fn
def set_plan_approval_fn(self, fn: Callable[[str], Awaitable[dict]]) -> None:
self._plan_approval_fn = fn
# βββ Plan mode toggle ββββββββββββββββββββββββββββββββββββ
def toggle_plan_mode(self) -> str:
if self.permission_mode == "plan":
self.permission_mode = self._pre_plan_mode or "default"
self._pre_plan_mode = None
self._plan_file_path = None
self._system_prompt = self._base_system_prompt
if self.use_openai and self._openai_messages:
self._openai_messages[0]["content"] = self._system_prompt
print_info(f"Exited plan mode β {self.permission_mode} mode")
return self.permission_mode
else:
self._pre_plan_mode = self.permission_mode
self.permission_mode = "plan"
self._plan_file_path = self._generate_plan_file_path()
self._system_prompt = self._base_system_prompt + self._build_plan_mode_prompt()
if self.use_openai and self._openai_messages:
self._openai_messages[0]["content"] = self._system_prompt
print_info(f"Entered plan mode. Plan file: {self._plan_file_path}")
return "plan"
def get_token_usage(self) -> dict:
return {"input": self.total_input_tokens, "output": self.total_output_tokens}
# βββ Main entry point ββββββββββββββββββββββββββββββββββββ
async def chat(self, user_message: str) -> None:
# Lazily connect to MCP servers on first chat (main agent only)
if not self._mcp_initialized and not self.is_sub_agent:
self._mcp_initialized = True
try:
await self._mcp_manager.load_and_connect()
mcp_defs = self._mcp_manager.get_tool_definitions()
if mcp_defs:
self.tools = self.tools + mcp_defs
except Exception as e:
print(f"[mcp] Init failed: {e}", flush=True)
self._aborted = False
coro = self._chat_openai(user_message) if self.use_openai else self._chat_anthropic(user_message)
self._current_task = asyncio.current_task()
try:
await coro
except asyncio.CancelledError:
self._aborted = True
finally:
self._current_task = None
if not self.is_sub_agent:
print_divider()
self._auto_save()
# βββ Sub-agent entry point ββββββββββββββββββββββββββββββββ
async def run_once(self, prompt: str) -> dict:
self._output_buffer = []
prev_in = self.total_input_tokens
prev_out = self.total_output_tokens
await self.chat(prompt)
text = "".join(self._output_buffer)
self._output_buffer = None
return {
"text": text,
"tokens": {
"input": self.total_input_tokens - prev_in,
"output": self.total_output_tokens - prev_out,
},
}
# βββ Output helper ββββββββββββββββββββββββββββββββββββββββ
def _emit_text(self, text: str) -> None:
if self._output_buffer is not None:
self._output_buffer.append(text)
else:
print_assistant_text(text)
# βββ REPL commands ββββββββββββββββββββββββββββββββββββββββ
def clear_history(self) -> None:
self._anthropic_messages = []
self._openai_messages = []
if self.use_openai:
self._openai_messages.append({"role": "system", "content": self._system_prompt})
self.total_input_tokens = 0
self.total_output_tokens = 0
self.total_cache_read_tokens = 0
self.total_cache_creation_tokens = 0
self.last_input_token_count = 0
print_info("Conversation cleared.")
def show_cost(self) -> None:
total = self._get_current_cost_usd()
budget_info = f" / ${self.max_cost_usd} budget" if self.max_cost_usd else ""
turn_info = f" | Turns: {self.current_turns}/{self.max_turns}" if self.max_turns else ""
cached = self.total_cache_read_tokens
billed_input = self.total_input_tokens + self.total_cache_creation_tokens + cached
hit_rate = round((cached / billed_input) * 100) if billed_input > 0 else 0
cache_info = (
f"\n Cache: {cached} read / {self.total_cache_creation_tokens} write ({hit_rate}% of input from cache)"
if (cached or self.total_cache_creation_tokens) else ""
)
print_info(f"Tokens: {self.total_input_tokens} in / {self.total_output_tokens} out{cache_info}\n Estimated cost: ${total:.4f}{budget_info}{turn_info}")
def _get_current_cost_usd(self) -> float:
# Per-million-token rates are resolved per model (see get_model_pricing).
# The default matches the fixed Claude multipliers (input $3, output $15,
# cache read 0.1x, cache write 1.25x); models with published rates override it.
M = 1_000_000
p = get_model_pricing(self.model)
return (
(self.total_input_tokens / M) * p["input"]
+ (self.total_cache_read_tokens / M) * p["cache_read"]
+ (self.total_cache_creation_tokens / M) * p["cache_write"]
+ (self.total_output_tokens / M) * p["output"]
)
def _check_budget(self) -> dict:
if self.max_cost_usd is not None and self._get_current_cost_usd() >= self.max_cost_usd:
return {"exceeded": True, "reason": f"Cost limit reached (${self._get_current_cost_usd():.4f} >= ${self.max_cost_usd})"}
if self.max_turns is not None and self.current_turns >= self.max_turns:
return {"exceeded": True, "reason": f"Turn limit reached ({self.current_turns} >= {self.max_turns})"}
return {"exceeded": False}
async def compact(self) -> None:
await self._compact_conversation()
# βββ /goal pursuit ββββββββββββββββββββββββββββββββββββββββ
# A prompt-based Stop hook: after each turn a separate evaluator model judges
# the condition; not-met feeds its reason into the next turn, met/impossible
# stop. See autonomy.py for the (verbatim) evaluator prompt.
def set_goal(self, condition: str) -> str:
"""Set the active goal and return the first-turn directive to run."""
self.active_goal = {"condition": condition, "iterations": 0, "started_at": time.time(), "last_reason": None}
print_info(f'β /goal active β Stop hook condition: "{condition}"')
return goal_directive(condition)
def show_goal(self) -> None:
"""`/goal` with no argument prints the current goal's status."""
if not self.active_goal:
print_info("No active goal. Set one with /goal <condition>.")
return
secs = time.time() - self.active_goal["started_at"]
last = f"\n last reason: {self.active_goal['last_reason']}" if self.active_goal["last_reason"] else ""
print_info(
f"β /goal active\n condition: {self.active_goal['condition']}\n"
f" iterations: {self.active_goal['iterations']}\n elapsed: {secs:.1f}s{last}"
)
async def pursue_goal(self, directive: str) -> None:
"""Pursue the active goal: run the directive turn, then loop
evaluate β (not met) feed reason back β next turn, until met, impossible,
budget/iteration cap, or interrupt."""
if not self.active_goal:
return
self.goal_stop = False
try:
await self.chat(directive)
# Evaluate the turn that just finished *before* any cap or next-turn
# decision, so the final turn's output is never left unjudged.
while self.active_goal and not self.goal_stop and not self._aborted:
verdict = await self._evaluate_goal(self.active_goal["condition"])
if verdict["ok"]:
turns = self.active_goal["iterations"] + 1
secs = time.time() - self.active_goal["started_at"]
plural = "" if turns == 1 else "s"
print_info(f"β Goal achieved ({turns} turn{plural}, {secs:.1f}s): {verdict['reason']}")
break
if verdict.get("impossible"):
print_info(f"Hooks: Prompt hook condition judged impossible: {verdict['reason']}")
break
# Not met: record and decide whether another turn is allowed.
self.active_goal["iterations"] += 1
self.active_goal["last_reason"] = verdict["reason"]
print_info(f"Hooks: Prompt hook condition was not met: {verdict['reason']}")
budget = self._check_budget()
if budget["exceeded"]:
print_info(f"Goal stopped: {budget['reason']}")
break
# Hard ceiling regardless of --max-turns: --max-turns only counts
# tool-executing turns (_check_budget), so a no-tool goal loop
# needs an unconditional backstop of its own.
if self.active_goal["iterations"] >= GOAL_MAX_ITERATIONS:
print_info(f"Goal stopped: reached {GOAL_MAX_ITERATIONS} iterations without meeting the condition.")
break
if self.goal_stop or self._aborted:
break
await self.chat(
f"Hooks: Prompt hook condition was not met: {verdict['reason']}\n\nKeep working toward the goal."
)
if self.goal_stop or self._aborted:
print_info("Goal pursuit interrupted.")
finally:
# Clear on any exit (met / impossible / capped / interrupted) so a
# stale goal never lingers. Real Claude Code keeps it session-scoped
# and resumable; we don't implement resume.
self.active_goal = None
async def _evaluate_goal(self, condition: str) -> dict:
"""One evaluator pass over the just-finished turn's transcript. The
transcript is sent as its own assistant message (framed by a preceding
user message as data-to-judge), so a crafted turn can't smuggle fake
user/judge text into the evaluator's context β real Claude Code likewise
sends the turn as a separate transcript message, not inlined."""
transcript = self._extract_last_assistant_text()
messages = [
{"role": "user", "content": GOAL_TRANSCRIPT_FRAMING},
{"role": "assistant", "content": transcript or "(no assistant output)"},
{"role": "user", "content": goal_judge_user_message(condition)},
]
try:
raw = await self._run_evaluator_query(GOAL_EVALUATOR_SYSTEM, messages)
return parse_goal_verdict(raw)
except Exception as e:
# Evaluator error β treat as not-met (never accidentally clears goal).
return {"ok": False, "reason": f"evaluator error: {e}", "impossible": False}
async def _run_evaluator_query(self, system: str, messages: list) -> str:
"""Send a role-separated evaluator query on whichever backend is
configured and return the model's text. Like _build_side_query but takes
a full messages array (that one is single-user-message, for memory
recall)."""
if self._anthropic_client:
resp = await self._anthropic_client.messages.create(
model=self.model, max_tokens=512, system=system, temperature=0, messages=messages,
)
return "".join(b.text for b in resp.content if b.type == "text")
if self._openai_client:
resp = await self._openai_client.chat.completions.create(
model=self.model, max_tokens=512, temperature=0,
messages=[{"role": "system", "content": system}, *messages],
)
return resp.choices[0].message.content or "" if resp.choices else ""
raise RuntimeError("no evaluator model available")
async def _run_classifier_query(self, system: str, user: str, max_tokens: int) -> str:
"""Single-message classifier query with a caller-chosen max_tokens, so the
two Auto Mode stages can size their budgets differently (stage 1 is a tiny
gate, stage 2 has room to think). temperature=0 for a deterministic
verdict, matching Claude Code's classifier."""
if self._anthropic_client:
resp = await self._anthropic_client.messages.create(
model=self.model, max_tokens=max_tokens, system=system, temperature=0,
messages=[{"role": "user", "content": user}],
)
return "".join(b.text for b in resp.content if b.type == "text")
if self._openai_client:
resp = await self._openai_client.chat.completions.create(
model=self.model, max_tokens=max_tokens, temperature=0,
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
)
return resp.choices[0].message.content or "" if resp.choices else ""
raise RuntimeError("no classifier model available")
def _extract_last_assistant_text(self) -> str:
"""The text of the most recent assistant turn, for the evaluator to
judge. Transcript-only: the action under judgement is the latest turn."""
if self.use_openai:
for m in reversed(self._openai_messages):
if m.get("role") == "assistant" and isinstance(m.get("content"), str):
return m["content"]
return ""
for m in reversed(self._anthropic_messages):
if m.get("role") != "assistant":
continue
content = m.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
b.get("text", "") for b in content
if isinstance(b, dict) and b.get("type") == "text"
)
return ""
# βββ /loop β recurring or self-paced prompt βββββββββββββββ
# Unlike /goal (a stop-hook gate), /loop actively reschedules itself: a fixed
# interval, or β with no interval β a pace the main model picks via the
# schedule_wakeup tool. See autonomy.py for the parser and tool schema.
async def run_loop(self, raw_input: str) -> None:
"""Entry point for the /loop command. Parses the input, then drives the
matching mode. Returns without looping if the input is malformed."""
spec = parse_loop_input(raw_input)
if "error" in spec:
print_info(spec["error"])
return
# Offer-cloud decision point (interval >=60min or daily wording). Real
# Claude Code asks whether to convert to a persistent cloud schedule that
# survives the session; this teaching CLI has no cloud, so we only
# surface it.
wants_cloud = (
(spec["mode"] == "interval" and spec["interval_seconds"] >= OFFER_CLOUD_THRESHOLD_SECONDS)
or is_daily_wording(raw_input)
)
if wants_cloud:
print_info(
"(Real Claude Code would offer to convert this to a persistent cloud schedule "
"that keeps running after the session ends. This teaching build has no cloud "
"backend β continuing in-session.)"
)
self.loop_stop = False
try:
if spec["mode"] == "interval":
await self._run_loop_interval(spec)
else:
await self._run_loop_dynamic(spec)
except asyncio.CancelledError:
print_info("Loop interrupted.")
async def _run_loop_interval(self, spec: dict) -> None:
"""Interval mode: re-run the prompt every N seconds until interrupted or
the iteration cap. Corresponds to Claude Code's in-session CronCreate
path (session-only, not persisted). We use a plain timer in place of the
cron engine + KAIROS daemon."""
print_info(
f"β³ /loop scheduled every {spec['interval_label']} (session-only, not persisted β "
"dies when this process exits). Ctrl+C to stop."
)
iterations = 0
while not self.loop_stop and not self._aborted:
iterations += 1
print_info(f"β³ loop tick {iterations}")
await self.chat(spec["prompt"])
budget = self._check_budget()
if budget["exceeded"]:
print_info(f"Loop stopped: {budget['reason']}")
break
# --max-turns also bounds loop ticks: _check_budget's turn counter
# only increments on tool-executing turns, so a plain-text loop would
# never hit it β treat --max-turns as a tick limit here too.
if self.max_turns is not None and iterations >= self.max_turns:
print_info(f"Loop stopped: tick limit reached ({iterations} >= {self.max_turns}).")
break
if iterations >= LOOP_MAX_ITERATIONS:
print_info(f"Loop stopped: reached {LOOP_MAX_ITERATIONS} ticks.")
break
interrupted = await self._interruptible_sleep(spec["interval_seconds"])
if interrupted:
print_info("Loop stopped.")
break
async def _run_loop_dynamic(self, spec: dict) -> None:
"""Dynamic mode: run the tick, then let the main model self-pace via
schedule_wakeup. If it scheduled a wakeup, wait the (clamped) delay and
run again with the prompt it passed back; if it didn't, the loop has
converged. Faithful to "dynamic pacing is decided by the main model, no
separate evaluator." schedule_wakeup is exposed only for the loop."""
print_info(
"β³ /loop dynamic (self-paced) β the model schedules its own next run, or ends the "
"loop. Ctrl+C to stop."
)
had_tool = any(t["name"] == "schedule_wakeup" for t in self.tools)
if not had_tool:
self.tools = self.tools + [SCHEDULE_WAKEUP_TOOL]
self.schedule_wakeup_enabled = True
prompt = spec["prompt"]
iterations = 0
try:
while not self.loop_stop and not self._aborted:
iterations += 1
self.pending_wakeup = None
await self.chat(dynamic_loop_directive(prompt))
if not self.pending_wakeup:
plural = "" if iterations == 1 else "s"
print_info(f"β³ Loop converged after {iterations} tick{plural} (model scheduled no wakeup).")
break
budget = self._check_budget()
if budget["exceeded"]:
print_info(f"Loop stopped: {budget['reason']}")
break
if self.max_turns is not None and iterations >= self.max_turns:
print_info(f"Loop stopped: tick limit reached ({iterations} >= {self.max_turns}).")
break
if iterations >= LOOP_MAX_ITERATIONS:
print_info(f"Loop stopped: reached {LOOP_MAX_ITERATIONS} ticks.")
break
delay = self.pending_wakeup["delay_seconds"]
print_info(f"β³ next run in {delay}s β {self.pending_wakeup['reason']}")
prompt = self.pending_wakeup["prompt"] or prompt
interrupted = await self._interruptible_sleep(delay)
if interrupted:
print_info("Loop stopped.")
break
finally:
# Remove schedule_wakeup so it isn't exposed outside the dynamic loop.
if not had_tool:
self.tools = [t for t in self.tools if t["name"] != "schedule_wakeup"]
self.schedule_wakeup_enabled = False
self.pending_wakeup = None
def _execute_schedule_wakeup(self, inp: dict) -> str:
"""schedule_wakeup executor: record the requested wakeup for the loop
driver. Delay is clamped to [60, 3600]; the driver reads pending_wakeup
after the turn converges."""
delay = clamp_wakeup_delay(inp.get("delaySeconds"))
reason = inp.get("reason") if isinstance(inp.get("reason"), str) else ""
prompt = inp.get("prompt") if isinstance(inp.get("prompt"), str) else ""
self.pending_wakeup = {"delay_seconds": delay, "reason": reason, "prompt": prompt}
return f"Wakeup scheduled in {delay}s. The loop will resume then; end your turn now."
async def _interruptible_sleep(self, seconds: float) -> bool:
"""Sleep that resolves early (returning True) if the loop is stopped or
the turn is aborted. Avoids blocking on a long interval past a Ctrl+C."""
import time as _time
start = _time.time()
while _time.time() - start < seconds:
if self.loop_stop or self._aborted:
return True
await asyncio.sleep(min(0.2, seconds))
return False
def stop_loop(self) -> None:
"""Stop a running /loop (called from the REPL's interrupt handler)."""
self.loop_stop = True
def stop_goal(self) -> None:
"""Stop a running /goal pursuit (called from the REPL's interrupt
handler). Takes effect at the next turn boundary β an in-flight turn is
aborted separately via abort()."""
self.goal_stop = True
# βββ Auto Mode β transcript-classifier permission gate ββββ
# In `auto` mode the classifier replaces the human confirm prompt: deny rules
# still hard-block, read-only tools fast-path through, everything else is
# judged by an LLM reading a reasoning-blind transcript projection.
async def _classify_tool_call(self, tool_name: str, inp: dict) -> dict:
"""Decide a tool call in Auto Mode. Returns allow/deny like
check_permission, or "confirm" to hand back to a human once the denial
limits trip.
Two-stage, mirroring Claude Code's `both` mode: stage 1 is an aggressive
cheap gate (no user intent, no ALLOW exceptions β block if any rule
*could* apply); if stage 1 allows, we're done in one call. If stage 1
blocks, stage 2 does the careful adjudication that DOES weigh user intent
and can clear the block. Stage 2's verdict is final."""
# Hard floor first: deny rules bind even here.
base = check_permission(tool_name, inp, "default", self._plan_file_path)
if base["action"] == "deny":
return base
# Fast-path: read-only / side-effect-free tools skip the classifier.
if tool_name in AUTO_MODE_FAST_PATH_TOOLS:
return {"action": "allow"}
if not self._anthropic_client and not self._openai_client:
# No evaluator available β fail closed. Defer to a human if present
# (interactive), else deny outright (headless: CC aborts here).
return self._auto_fallback(f"{tool_name} (auto-mode classifier unavailable)")
try:
rules = load_auto_mode_rules()
history = self._openai_messages if self.use_openai else self._anthropic_messages
transcript = build_classifier_transcript(history, {"tool_name": tool_name, "input": inp})
system = build_classifier_system(rules)
# CLAUDE.md rides in the user message, not the system prompt β it is
# untrusted repo content.
claude_md = load_claude_md()
# Stage 1 β aggressive cheap gate (small token budget: just <block>β¦).
s1_raw = await self._run_classifier_query(system, classifier_user_message(rules, transcript, rules["suffix_stage1"], claude_md), 256)
s1 = parse_block_verdict(s1_raw)
if not s1["block"]:
verdict = s1 # stage 1 cleared it β allow (one call)
else:
# Stage 2 β careful adjudication (weighs user intent, can clear).
# More tokens: stage 2 may emit a <thinking> block first.
s2_raw = await self._run_classifier_query(system, classifier_user_message(rules, transcript, rules["suffix_stage2"], claude_md), 1024)
verdict = parse_block_verdict(s2_raw)
except Exception as e:
# Any setup or classifier error β fail closed (block), matching CC's
# iron gate. Wrapping the asset load here keeps a missing/bad rules
# file from crashing the turn and orphaning the tool_use.
verdict = {"block": True, "reason": f"classifier error: {e}"}
if not verdict["block"]:
self.auto_consecutive_denials = 0
return {"action": "allow"}
self.auto_consecutive_denials += 1
self.auto_total_denials += 1
if (
self.auto_consecutive_denials >= DENIAL_LIMITS["max_consecutive"]
or self.auto_total_denials >= DENIAL_LIMITS["max_total"]
):
# Too many denials β the classifier may be stuck. Hand back to a human
# if interactive; deny in headless (CC aborts the agent here).
print_info("Auto Mode: denial limit reached β handing back to manual confirmation.")
return self._auto_fallback(f"[Auto Mode blocked] {verdict['reason']}")
return {"action": "deny", "message": f"[Auto Mode] {verdict['reason']}"}
def _auto_fallback(self, message: str) -> dict:
"""Auto Mode fail-safe: defer to a human confirm if one is available,
else deny (headless). Never returns "allow" β the point is to not run an
unjudged action."""
if self.confirm_fn:
return {"action": "confirm", "message": message}
return {"action": "deny", "message": f"{message} (headless β denied)"}
def _child_permission_mode(self) -> str:
"""Permission mode a spawned sub-agent inherits. plan and auto must carry
through β a sub-agent otherwise runs bypassPermissions, so in Auto Mode
the main model could launder a blocked action through
agent(prompt="git push origin main") and have the sub-agent run it
unclassified. Claude Code puts every sub-agent tool call through
canUseTool individually."""
if self.permission_mode == "plan":
return "plan"
if self.permission_mode == "auto":
return "auto"
return "bypassPermissions"
# βββ Session ββββββββββββββββββββββββββββββββββββββββββββββ
def restore_session(self, data: dict) -> None:
if data.get("anthropicMessages"):
self._anthropic_messages = data["anthropicMessages"]
if data.get("openaiMessages"):
self._openai_messages = data["openaiMessages"]
print_info(f"Session restored ({self._get_message_count()} messages).")
def _get_message_count(self) -> int:
return len(self._openai_messages) if self.use_openai else len(self._anthropic_messages)
def _auto_save(self) -> None:
try:
save_session(self.session_id, {
"metadata": {
"id": self.session_id,
"model": self.model,
"cwd": str(Path.cwd()),
"startTime": self.session_start_time,
"messageCount": self._get_message_count(),
},
"anthropicMessages": self._anthropic_messages if not self.use_openai else None,
"openaiMessages": self._openai_messages if self.use_openai else None,
})
except Exception:
pass
# βββ Autocompact ββββββββββββββββββββββββββββββββββββββββββ
async def _check_and_compact(self) -> None:
if self.last_input_token_count > self.effective_window * 0.85:
print_info("Context window filling up, compacting conversation...")
await self._compact_conversation()
async def _compact_conversation(self) -> None:
if self.use_openai:
await self._compact_openai()
else:
await self._compact_anthropic()
print_info("Conversation compacted.")
async def _compact_anthropic(self) -> None:
# Invariant: caller must ensure the last message is a plain user-text
# message (not a tool_result). We slice it off below; if it were a
# tool_result, the preceding assistant's tool_use would be orphaned
# and the API would reject the summarize call.
if len(self._anthropic_messages) < 4:
return
last_user_msg = self._anthropic_messages[-1]
summary_resp = await self._anthropic_client.messages.create(
model=self.model,
max_tokens=2048,
system="You are a conversation summarizer. Be concise but preserve important details.",
messages=[
*self._anthropic_messages[:-1],
{"role": "user", "content": "Summarize the conversation so far in a concise paragraph, preserving key decisions, file paths, and context needed to continue the work."},
],
)
summary_text = summary_resp.content[0].text if summary_resp.content and summary_resp.content[0].type == "text" else "No summary available."
self._anthropic_messages = [
{"role": "user", "content": f"[Previous conversation summary]\n{summary_text}"},
{"role": "assistant", "content": "Understood. I have the context from our previous conversation. How can I continue helping?"},
]
if last_user_msg.get("role") == "user":