Files
chauncygu 1d4ffa964d Update README.MD and add nano-claude-code v3.0 + original-source-code/src
- README.MD: add original-source-code and nano-claude-code sections, update
  overview table (4 subprojects), add v3.0 news entry, expand comparison table
  with memory/multi-agent/skills dimensions
- nano-claude-code v3.0: multi-agent package (multi_agent/), memory package
  (memory/), skill package (skill/) with built-in /commit and /review skills,
  context compression (compaction.py), tool registry plugin system, diff view,
  17 slash commands, 18 built-in tools, 101 tests (~5000 lines total)
- original-source-code/src: add raw TypeScript source tree (1884 files)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:26:29 -07:00

175 lines
5.8 KiB
Python

"""Core agent loop: neutral message format, multi-provider streaming."""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Generator
from tool_registry import get_tool_schemas
from tools import execute_tool
import tools as _tools_init # ensure built-in tools are registered on import
from providers import stream, AssistantTurn, TextChunk, ThinkingChunk, detect_provider
from compaction import maybe_compact
# ── Re-export event types (used by nano_claude.py) ────────────────────────
__all__ = [
"AgentState", "run",
"TextChunk", "ThinkingChunk",
"ToolStart", "ToolEnd", "TurnDone", "PermissionRequest",
]
@dataclass
class AgentState:
"""Mutable session state. messages use the neutral provider-independent format."""
messages: list = field(default_factory=list)
total_input_tokens: int = 0
total_output_tokens: int = 0
turn_count: int = 0
@dataclass
class ToolStart:
name: str
inputs: dict
@dataclass
class ToolEnd:
name: str
result: str
permitted: bool = True
@dataclass
class TurnDone:
input_tokens: int
output_tokens: int
@dataclass
class PermissionRequest:
description: str
granted: bool = False
# ── Agent loop ─────────────────────────────────────────────────────────────
def run(
user_message: str,
state: AgentState,
config: dict,
system_prompt: str,
depth: int = 0,
cancel_check=None,
) -> Generator:
"""
Multi-turn agent loop (generator).
Yields: TextChunk | ThinkingChunk | ToolStart | ToolEnd |
PermissionRequest | TurnDone
Args:
depth: sub-agent nesting depth, 0 for top-level
cancel_check: callable returning True to abort the loop early
"""
# Append user turn in neutral format
state.messages.append({"role": "user", "content": user_message})
# Inject runtime metadata into config so tools (e.g. Agent) can access it
config = {**config, "_depth": depth, "_system_prompt": system_prompt}
while True:
if cancel_check and cancel_check():
return
state.turn_count += 1
assistant_turn: AssistantTurn | None = None
# Compact context if approaching window limit
maybe_compact(state, config)
# Stream from provider (auto-detected from model name)
for event in stream(
model=config["model"],
system=system_prompt,
messages=state.messages,
tool_schemas=get_tool_schemas(),
config=config,
):
if isinstance(event, (TextChunk, ThinkingChunk)):
yield event
elif isinstance(event, AssistantTurn):
assistant_turn = event
if assistant_turn is None:
break
# Record assistant turn in neutral format
state.messages.append({
"role": "assistant",
"content": assistant_turn.text,
"tool_calls": assistant_turn.tool_calls,
})
state.total_input_tokens += assistant_turn.in_tokens
state.total_output_tokens += assistant_turn.out_tokens
yield TurnDone(assistant_turn.in_tokens, assistant_turn.out_tokens)
if not assistant_turn.tool_calls:
break # No tools → conversation turn complete
# ── Execute tools ────────────────────────────────────────────────
for tc in assistant_turn.tool_calls:
yield ToolStart(tc["name"], tc["input"])
# Permission gate
permitted = _check_permission(tc, config)
if not permitted:
req = PermissionRequest(description=_permission_desc(tc))
yield req
permitted = req.granted
if not permitted:
result = "Denied: user rejected this operation"
else:
result = execute_tool(
tc["name"], tc["input"],
permission_mode="accept-all", # already gate-checked above
config=config,
)
yield ToolEnd(tc["name"], result, permitted)
# Append tool result in neutral format
state.messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"name": tc["name"],
"content": result,
})
# ── Helpers ───────────────────────────────────────────────────────────────
def _check_permission(tc: dict, config: dict) -> bool:
"""Return True if operation is auto-approved (no need to ask user)."""
perm_mode = config.get("permission_mode", "auto")
if perm_mode == "accept-all":
return True
if perm_mode == "manual":
return False # always ask
# "auto" mode: only ask for writes and non-safe bash
name = tc["name"]
if name in ("Read", "Glob", "Grep", "WebFetch", "WebSearch"):
return True
if name == "Bash":
from tools import _is_safe_bash
return _is_safe_bash(tc["input"].get("command", ""))
return False # Write, Edit → ask
def _permission_desc(tc: dict) -> str:
name = tc["name"]
inp = tc["input"]
if name == "Bash": return f"Run: {inp.get('command', '')}"
if name == "Write": return f"Write to: {inp.get('file_path', '')}"
if name == "Edit": return f"Edit: {inp.get('file_path', '')}"
return f"{name}({list(inp.values())[:1]})"