agentic-loop 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from dataclasses import asdict, dataclass, field
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def _utc_now() -> str:
12
+ return datetime.now(timezone.utc).isoformat()
13
+
14
+
15
+ @dataclass
16
+ class TriageItem:
17
+ id: str
18
+ title: str
19
+ status: str = "pending"
20
+ detail: str = ""
21
+ created_at: str = field(default_factory=_utc_now)
22
+ updated_at: str = field(default_factory=_utc_now)
23
+ run_id: str | None = None
24
+
25
+ @classmethod
26
+ def create(cls, title: str, *, detail: str = "") -> TriageItem:
27
+ return cls(id=uuid.uuid4().hex[:10], title=title, detail=detail)
28
+
29
+
30
+ @dataclass
31
+ class ProjectState:
32
+ attempted: list[str] = field(default_factory=list)
33
+ passed: list[str] = field(default_factory=list)
34
+ pending: list[str] = field(default_factory=list)
35
+ last_run_at: str | None = None
36
+ last_run_id: str | None = None
37
+ notes: str = ""
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: dict[str, Any]) -> ProjectState:
41
+ return cls(
42
+ attempted=list(data.get("attempted", [])),
43
+ passed=list(data.get("passed", [])),
44
+ pending=list(data.get("pending", [])),
45
+ last_run_at=data.get("last_run_at"),
46
+ last_run_id=data.get("last_run_id"),
47
+ notes=data.get("notes", ""),
48
+ )
49
+
50
+
51
+ class StateStore:
52
+ """Persistent loop memory on disk (models forget; the repo does not)."""
53
+
54
+ def __init__(self, state_dir: Path) -> None:
55
+ self.state_dir = state_dir
56
+ self.state_dir.mkdir(parents=True, exist_ok=True)
57
+ self.state_path = self.state_dir / "state.json"
58
+ self.triage_path = self.state_dir / "triage.json"
59
+
60
+ def load_state(self) -> ProjectState:
61
+ if not self.state_path.exists():
62
+ return ProjectState()
63
+ data = json.loads(self.state_path.read_text(encoding="utf-8"))
64
+ return ProjectState.from_dict(data)
65
+
66
+ def save_state(self, state: ProjectState) -> None:
67
+ self.state_path.write_text(
68
+ json.dumps(asdict(state), ensure_ascii=False, indent=2) + "\n",
69
+ encoding="utf-8",
70
+ )
71
+
72
+ def load_triage(self) -> list[TriageItem]:
73
+ if not self.triage_path.exists():
74
+ return []
75
+ raw = json.loads(self.triage_path.read_text(encoding="utf-8"))
76
+ return [TriageItem(**item) for item in raw.get("items", [])]
77
+
78
+ def save_triage(self, items: list[TriageItem]) -> None:
79
+ payload = {"items": [asdict(item) for item in items]}
80
+ self.triage_path.write_text(
81
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
82
+ encoding="utf-8",
83
+ )
84
+
85
+ def record_run(self, *, run_id: str, summary: str, passed: bool) -> ProjectState:
86
+ state = self.load_state()
87
+ state.last_run_at = _utc_now()
88
+ state.last_run_id = run_id
89
+ key = summary[:200]
90
+ if key not in state.attempted:
91
+ state.attempted.append(key)
92
+ if passed:
93
+ if key not in state.passed:
94
+ state.passed.append(key)
95
+ state.pending = [item for item in state.pending if item != key]
96
+ elif key not in state.pending:
97
+ state.pending.append(key)
98
+ self.save_state(state)
99
+ return state
100
+
101
+ def add_triage(self, title: str, *, detail: str = "", dedupe: bool = True) -> TriageItem:
102
+ items = self.load_triage()
103
+ if dedupe:
104
+ for item in items:
105
+ if item.title == title and item.status == "pending":
106
+ return item
107
+ entry = TriageItem.create(title, detail=detail)
108
+ items.append(entry)
109
+ self.save_triage(items)
110
+ return entry
111
+
112
+ def update_triage_status(self, item_id: str, status: str) -> TriageItem | None:
113
+ items = self.load_triage()
114
+ for item in items:
115
+ if item.id == item_id:
116
+ item.status = status
117
+ item.updated_at = _utc_now()
118
+ self.save_triage(items)
119
+ return item
120
+ return None
121
+
122
+ def format_markdown(self) -> str:
123
+ state = self.load_state()
124
+ triage = self.load_triage()
125
+ lines = [
126
+ "# Agentic Loop State",
127
+ "",
128
+ f"- last_run_at: {state.last_run_at or '(none)'}",
129
+ f"- last_run_id: {state.last_run_id or '(none)'}",
130
+ "",
131
+ "## Attempted",
132
+ ]
133
+ lines.extend(f"- {item}" for item in state.attempted[-20:]) or ["- (none)"]
134
+ lines.extend(["", "## Passed"])
135
+ lines.extend(f"- {item}" for item in state.passed[-20:]) or ["- (none)"]
136
+ lines.extend(["", "## Pending"])
137
+ lines.extend(f"- {item}" for item in state.pending) or ["- (none)"]
138
+ lines.extend(["", "## Triage Inbox"])
139
+ pending_triage = [t for t in triage if t.status == "pending"]
140
+ if not pending_triage:
141
+ lines.append("- (empty)")
142
+ else:
143
+ for item in pending_triage[:20]:
144
+ lines.append(f"- [{item.id}] {item.title}")
145
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Callable
5
+
6
+ from agentic_loop.api import execute_run
7
+ from agentic_loop.config import RunConfig
8
+ from agentic_loop.loop import LoopEvent
9
+ from agentic_loop.orchestration.automations import AutomationResult, run_interval
10
+ from agentic_loop.orchestration.goal import GoalEvaluation, GoalRunner
11
+ from agentic_loop.orchestration.memory import StateStore, TriageItem
12
+ from agentic_loop.orchestration.subagents import load_agent_specs, merge_run_config
13
+ from agentic_loop.skills.loader import SkillLoader
14
+ from agentic_loop.terminal import Terminal
15
+
16
+
17
+ class Orchestrator:
18
+ """Loop Engineering orchestration layer (memory, skills, sub-agents, goal, automations)."""
19
+
20
+ def __init__(self, config: RunConfig) -> None:
21
+ self.config = config
22
+ self.memory = StateStore(config.state_dir)
23
+ self.skills = SkillLoader(config.cwd, config.state_dir)
24
+ self.agents_dir = config.state_dir / "agents"
25
+ self.agent_specs = load_agent_specs(self.agents_dir)
26
+
27
+ def _system_prompt(self, *, skill: str | None = None, agent: str | None = None) -> str | None:
28
+ parts: list[str] = []
29
+ if skill:
30
+ parts.append(self.skills.load_skill(skill))
31
+ if agent and agent in self.agent_specs:
32
+ spec_prompt = self.agent_specs[agent].system_prompt.strip()
33
+ if spec_prompt:
34
+ parts.append(spec_prompt)
35
+ if not parts:
36
+ return self.skills.default_system_prompt()
37
+ return "\n\n".join(parts)
38
+
39
+ async def run(
40
+ self,
41
+ prompt: str,
42
+ *,
43
+ skill: str | None = None,
44
+ agent: str | None = None,
45
+ on_event: Callable[[LoopEvent], None] | None = None,
46
+ ) -> tuple[Terminal, str]:
47
+ config = self.config
48
+ system_prompt = self._system_prompt(skill=skill, agent=agent)
49
+
50
+ if agent and agent in self.agent_specs:
51
+ config, agent_prompt, _tools = merge_run_config(config, self.agent_specs[agent])
52
+ if agent_prompt:
53
+ system_prompt = "\n\n".join(filter(None, [system_prompt, agent_prompt]))
54
+
55
+ state_context = self.memory.format_markdown()
56
+ enriched_prompt = f"{prompt}\n\n--- project state ---\n{state_context}"
57
+
58
+ terminal, journal = await execute_run(
59
+ enriched_prompt,
60
+ config=config,
61
+ system_prompt=system_prompt,
62
+ on_event=on_event,
63
+ )
64
+ passed = terminal.kind.value == "completed"
65
+ summary = terminal.content or terminal.error or prompt[:200]
66
+ self.memory.record_run(run_id=journal.run_id, summary=summary, passed=passed)
67
+ return terminal, journal.run_id
68
+
69
+ async def run_goal(
70
+ self,
71
+ *,
72
+ condition: str,
73
+ prompt: str,
74
+ skill: str | None = None,
75
+ agent: str | None = None,
76
+ max_rounds: int = 10,
77
+ on_event: Callable[[LoopEvent], None] | None = None,
78
+ ) -> tuple[Terminal, list[GoalEvaluation]]:
79
+ config = self.config
80
+ system_prompt = self._system_prompt(skill=skill, agent=agent)
81
+ if agent and agent in self.agent_specs:
82
+ config, agent_prompt, _ = merge_run_config(config, self.agent_specs[agent])
83
+ if agent_prompt:
84
+ system_prompt = "\n\n".join(filter(None, [system_prompt, agent_prompt]))
85
+
86
+ runner = GoalRunner(config=config)
87
+ terminal, evaluations = await runner.run_until_goal(
88
+ condition=condition,
89
+ prompt=prompt,
90
+ system_prompt=system_prompt,
91
+ max_rounds=max_rounds,
92
+ on_event=on_event,
93
+ )
94
+ return terminal, evaluations
95
+
96
+ async def automation(
97
+ self,
98
+ prompt: str,
99
+ *,
100
+ every: str,
101
+ skill: str | None = None,
102
+ once: bool = False,
103
+ on_event: Callable[[LoopEvent], None] | None = None,
104
+ ) -> AutomationResult:
105
+ async def _task() -> None:
106
+ terminal, run_id = await self.run(prompt, skill=skill, on_event=on_event)
107
+ if terminal.kind.value != "completed":
108
+ self.memory.add_triage(
109
+ f"Automation run needs review ({run_id})",
110
+ detail=terminal.error or terminal.content or "",
111
+ )
112
+
113
+ return await run_interval(every=every, task=_task, once=once)
114
+
115
+ def triage_pending(self) -> list[TriageItem]:
116
+ return [item for item in self.memory.load_triage() if item.status == "pending"]
117
+
118
+ def state_markdown(self) -> str:
119
+ return self.memory.format_markdown()
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class SubAgentSpec:
12
+ name: str
13
+ description: str = ""
14
+ system_prompt: str = ""
15
+ model: str | None = None
16
+ allow_bash: bool | None = None
17
+ tools: list[str] | None = None
18
+ max_turns: int | None = None
19
+
20
+
21
+ def load_agent_specs(agents_dir: Path) -> dict[str, SubAgentSpec]:
22
+ if not agents_dir.exists():
23
+ return {}
24
+
25
+ specs: dict[str, SubAgentSpec] = {}
26
+ for path in sorted(agents_dir.glob("*.toml")):
27
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
28
+ name = str(data.get("name") or path.stem)
29
+ specs[name] = SubAgentSpec(
30
+ name=name,
31
+ description=str(data.get("description", "")),
32
+ system_prompt=str(data.get("system_prompt", "")),
33
+ model=data.get("model"),
34
+ allow_bash=data.get("allow_bash"),
35
+ tools=list(data["tools"]) if data.get("tools") else None,
36
+ max_turns=data.get("max_turns"),
37
+ )
38
+ return specs
39
+
40
+
41
+ def merge_run_config(base, spec: SubAgentSpec):
42
+ from agentic_loop.config import RunConfig
43
+
44
+ overrides: dict = {}
45
+ if spec.model:
46
+ overrides["model"] = spec.model
47
+ if spec.allow_bash is not None:
48
+ overrides["allow_bash"] = spec.allow_bash
49
+ if spec.max_turns is not None:
50
+ overrides["max_turns"] = spec.max_turns
51
+
52
+ merged = RunConfig(
53
+ cwd=base.cwd,
54
+ model=overrides.get("model", base.model),
55
+ evaluator_model=base.evaluator_model,
56
+ api_key=base.api_key,
57
+ base_url=base.base_url,
58
+ max_turns=overrides.get("max_turns", base.max_turns),
59
+ allow_bash=overrides.get("allow_bash", base.allow_bash),
60
+ dry_run=base.dry_run,
61
+ stream=base.stream,
62
+ max_retries=base.max_retries,
63
+ tool_timeout=base.tool_timeout,
64
+ agentic_loop_dir=base.agentic_loop_dir,
65
+ )
66
+ return merged, spec.system_prompt or None, spec.tools
File without changes
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ class SkillLoader:
7
+ def __init__(self, cwd: Path, state_dir: Path) -> None:
8
+ self.cwd = cwd
9
+ self.search_dirs = [
10
+ state_dir / "skills",
11
+ cwd / "skills",
12
+ cwd / ".agentic-loop" / "skills",
13
+ ]
14
+
15
+ def _find_skill_file(self, name: str) -> Path | None:
16
+ for base in self.search_dirs:
17
+ direct = base / name / "SKILL.md"
18
+ if direct.is_file():
19
+ return direct
20
+ flat = base / f"{name}.md"
21
+ if flat.is_file():
22
+ return flat
23
+ return None
24
+
25
+ def load_skill(self, name: str) -> str:
26
+ path = self._find_skill_file(name)
27
+ if not path:
28
+ raise ValueError(
29
+ f"Skill '{name}' not found. Expected SKILL.md under one of: "
30
+ + ", ".join(str(d / name) for d in self.search_dirs)
31
+ )
32
+ return path.read_text(encoding="utf-8").strip()
33
+
34
+ def list_skills(self) -> list[str]:
35
+ names: set[str] = set()
36
+ for base in self.search_dirs:
37
+ if not base.exists():
38
+ continue
39
+ for path in base.rglob("SKILL.md"):
40
+ names.add(path.parent.name)
41
+ for path in base.glob("*.md"):
42
+ names.add(path.stem)
43
+ return sorted(names)
44
+
45
+ def default_system_prompt(self) -> str | None:
46
+ parts: list[str] = []
47
+ for name in self.list_skills():
48
+ try:
49
+ parts.append(self.load_skill(name))
50
+ except ValueError:
51
+ continue
52
+ return "\n\n".join(parts) if parts else None
agentic_loop/state.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from typing import Any, Literal
5
+
6
+ TransitionReason = Literal[
7
+ "next_turn",
8
+ "max_output_tokens_recovery",
9
+ "stop_hook_blocking",
10
+ ]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LoopState:
15
+ messages: list[dict[str, Any]]
16
+ turn_count: int = 0
17
+ transition: TransitionReason | None = None
18
+
19
+ def with_messages(self, messages: list[dict[str, Any]]) -> LoopState:
20
+ return replace(self, messages=list(messages))
21
+
22
+ def next_turn(self, messages: list[dict[str, Any]], reason: TransitionReason = "next_turn") -> LoopState:
23
+ return replace(
24
+ self,
25
+ messages=list(messages),
26
+ turn_count=self.turn_count + 1,
27
+ transition=reason,
28
+ )
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class TerminalKind(str, Enum):
10
+ COMPLETED = "completed"
11
+ MAX_TURNS = "max_turns"
12
+ ERROR = "error"
13
+ ABORTED = "aborted"
14
+ MODEL_ERROR = "model_error"
15
+
16
+
17
+ class Terminal(BaseModel):
18
+ kind: TerminalKind
19
+ content: str | None = None
20
+ error: str | None = None
21
+ turns: int = 0
22
+
23
+ @classmethod
24
+ def completed(cls, content: str | None, *, turns: int) -> Terminal:
25
+ return cls(kind=TerminalKind.COMPLETED, content=content, turns=turns)
26
+
27
+ @classmethod
28
+ def max_turns(cls, *, turns: int, last_content: str | None = None) -> Terminal:
29
+ return cls(
30
+ kind=TerminalKind.MAX_TURNS,
31
+ content=last_content,
32
+ turns=turns,
33
+ error=f"Reached max turns ({turns})",
34
+ )
35
+
36
+ @classmethod
37
+ def failed(cls, message: str, *, turns: int = 0) -> Terminal:
38
+ return cls(kind=TerminalKind.ERROR, error=message, turns=turns)
39
+
40
+ @classmethod
41
+ def aborted(cls, *, turns: int) -> Terminal:
42
+ return cls(kind=TerminalKind.ABORTED, turns=turns, error="Run aborted")
43
+
44
+ @classmethod
45
+ def model_error(cls, message: str, *, turns: int) -> Terminal:
46
+ return cls(kind=TerminalKind.MODEL_ERROR, error=message, turns=turns)
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return self.model_dump(exclude_none=True)
50
+
51
+ @property
52
+ def exit_code(self) -> int:
53
+ if self.kind == TerminalKind.COMPLETED:
54
+ return 0
55
+ return 1
@@ -0,0 +1,3 @@
1
+ from agentic_loop.tools.registry import ToolRegistry, build_default_registry
2
+
3
+ __all__ = ["ToolRegistry", "build_default_registry"]
@@ -0,0 +1,3 @@
1
+ from agentic_loop.tools.registry import build_default_registry
2
+
3
+ __all__ = ["build_default_registry"]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from agentic_loop.connectors.mcp import MCPConnector, MCPServerConfig
6
+ from agentic_loop.tools.registry import ToolRegistry
7
+
8
+
9
+ async def register_mcp_tools(
10
+ registry: ToolRegistry,
11
+ connector: MCPConnector,
12
+ *,
13
+ prefix: str = "mcp",
14
+ ) -> list[str]:
15
+ """Register MCP server tools into a ToolRegistry with optional name prefix."""
16
+ tools = await connector.list_tools()
17
+ registered: list[str] = []
18
+
19
+ for spec in tools:
20
+ name = spec["name"]
21
+ tool_name = f"{prefix}_{name}" if prefix else name
22
+ schema = spec.get("inputSchema") or {"type": "object", "properties": {}}
23
+
24
+ async def handler(args: dict[str, Any], *, _tool=name) -> str:
25
+ result = await connector.call_tool(_tool, args)
26
+ return result.message if result.ok else f"Error: {result.message}"
27
+
28
+ registry.register(
29
+ tool_name,
30
+ spec.get("description") or f"MCP tool {name}",
31
+ schema,
32
+ handler,
33
+ )
34
+ registered.append(tool_name)
35
+
36
+ return registered