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.
- agentic_loop/__init__.py +31 -0
- agentic_loop/abort.py +19 -0
- agentic_loop/api.py +119 -0
- agentic_loop/cli.py +299 -0
- agentic_loop/config.py +73 -0
- agentic_loop/connectors/__init__.py +0 -0
- agentic_loop/connectors/base.py +39 -0
- agentic_loop/connectors/mcp.py +99 -0
- agentic_loop/llm/__init__.py +0 -0
- agentic_loop/llm/client.py +44 -0
- agentic_loop/llm/openai_compat.py +163 -0
- agentic_loop/llm/retry.py +41 -0
- agentic_loop/loop.py +228 -0
- agentic_loop/observability/__init__.py +0 -0
- agentic_loop/observability/journal.py +46 -0
- agentic_loop/orchestration/__init__.py +0 -0
- agentic_loop/orchestration/automations.py +54 -0
- agentic_loop/orchestration/goal.py +114 -0
- agentic_loop/orchestration/memory.py +145 -0
- agentic_loop/orchestration/orchestrator.py +119 -0
- agentic_loop/orchestration/subagents.py +66 -0
- agentic_loop/skills/__init__.py +0 -0
- agentic_loop/skills/loader.py +52 -0
- agentic_loop/state.py +28 -0
- agentic_loop/terminal.py +55 -0
- agentic_loop/tools/__init__.py +3 -0
- agentic_loop/tools/builtin.py +3 -0
- agentic_loop/tools/mcp_bridge.py +36 -0
- agentic_loop/tools/registry.py +222 -0
- agentic_loop/worktree/__init__.py +0 -0
- agentic_loop/worktree/manager.py +47 -0
- agentic_loop-0.3.0.dist-info/METADATA +110 -0
- agentic_loop-0.3.0.dist-info/RECORD +37 -0
- agentic_loop-0.3.0.dist-info/WHEEL +5 -0
- agentic_loop-0.3.0.dist-info/entry_points.txt +2 -0
- agentic_loop-0.3.0.dist-info/licenses/LICENSE +21 -0
- agentic_loop-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|
agentic_loop/terminal.py
ADDED
|
@@ -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,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
|