loopengt 0.1.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.
- loopengt/__init__.py +31 -0
- loopengt/adapters/__init__.py +1 -0
- loopengt/adapters/antigravity/__init__.py +1 -0
- loopengt/adapters/antigravity/adapter.py +55 -0
- loopengt/adapters/antigravity/commands.py +21 -0
- loopengt/adapters/base.py +51 -0
- loopengt/adapters/claude_code/__init__.py +1 -0
- loopengt/adapters/claude_code/adapter.py +55 -0
- loopengt/adapters/claude_code/commands.py +16 -0
- loopengt/adapters/codex/__init__.py +1 -0
- loopengt/adapters/codex/adapter.py +52 -0
- loopengt/adapters/codex/commands.py +16 -0
- loopengt/adapters/cursor/__init__.py +1 -0
- loopengt/adapters/cursor/adapter.py +56 -0
- loopengt/adapters/cursor/commands.py +29 -0
- loopengt/adapters/generic/__init__.py +1 -0
- loopengt/adapters/generic/terminal.py +82 -0
- loopengt/cli/__init__.py +1 -0
- loopengt/cli/commands/__init__.py +1 -0
- loopengt/cli/commands/design.py +171 -0
- loopengt/cli/commands/doctor.py +110 -0
- loopengt/cli/commands/eval.py +105 -0
- loopengt/cli/commands/init.py +131 -0
- loopengt/cli/commands/mcp_serve.py +57 -0
- loopengt/cli/commands/run.py +99 -0
- loopengt/cli/commands/template.py +145 -0
- loopengt/cli/commands/trace.py +114 -0
- loopengt/cli/formatters.py +125 -0
- loopengt/cli/main.py +66 -0
- loopengt/core/__init__.py +1 -0
- loopengt/core/evals/__init__.py +1 -0
- loopengt/core/evals/judges.py +216 -0
- loopengt/core/evals/metrics.py +119 -0
- loopengt/core/evals/regression.py +157 -0
- loopengt/core/memory/__init__.py +1 -0
- loopengt/core/memory/retrieval.py +124 -0
- loopengt/core/memory/store.py +184 -0
- loopengt/core/memory/summarizer.py +97 -0
- loopengt/core/models/__init__.py +43 -0
- loopengt/core/models/agent.py +126 -0
- loopengt/core/models/loop_spec.py +251 -0
- loopengt/core/models/policy.py +131 -0
- loopengt/core/models/state.py +271 -0
- loopengt/core/models/tool.py +105 -0
- loopengt/core/runtime/__init__.py +1 -0
- loopengt/core/runtime/checkpoint.py +152 -0
- loopengt/core/runtime/executor.py +463 -0
- loopengt/core/runtime/handoff.py +139 -0
- loopengt/core/runtime/scheduler.py +168 -0
- loopengt/core/tracing/__init__.py +1 -0
- loopengt/core/tracing/events.py +95 -0
- loopengt/core/tracing/exporters.py +158 -0
- loopengt/core/tracing/store.py +202 -0
- loopengt/mcp/__init__.py +1 -0
- loopengt/mcp/client/__init__.py +1 -0
- loopengt/mcp/client/manager.py +118 -0
- loopengt/mcp/client/tools.py +107 -0
- loopengt/mcp/server/__init__.py +1 -0
- loopengt/mcp/server/prompts.py +82 -0
- loopengt/mcp/server/resources.py +75 -0
- loopengt/mcp/server/server.py +50 -0
- loopengt/mcp/server/tools.py +214 -0
- loopengt/mcp/shared/__init__.py +1 -0
- loopengt/mcp/shared/schemas.py +91 -0
- loopengt/plugins/__init__.py +1 -0
- loopengt/plugins/base.py +90 -0
- loopengt/plugins/loader.py +130 -0
- loopengt/plugins/manifest.py +70 -0
- loopengt/plugins/registry.py +146 -0
- loopengt/prompts/LOOPENGT.md +60 -0
- loopengt/prompts/__init__.py +1 -0
- loopengt/storage/__init__.py +1 -0
- loopengt/storage/jsonl.py +84 -0
- loopengt/storage/sqlite.py +102 -0
- loopengt/templates/__init__.py +1 -0
- loopengt/templates/builtins/handoff_loop/LOOPENGS.md +10 -0
- loopengt/templates/builtins/planner_executor/LOOPENGS.md +29 -0
- loopengt/templates/builtins/research_architect/LOOPENGS.md +17 -0
- loopengt/templates/builtins/reviewer_retry/LOOPENGS.md +29 -0
- loopengt/templates/builtins/supervisor_workers/LOOPENGS.md +29 -0
- loopengt/templates/loader.py +38 -0
- loopengt/templates/registry.py +85 -0
- loopengt-0.1.0.dist-info/METADATA +275 -0
- loopengt-0.1.0.dist-info/RECORD +87 -0
- loopengt-0.1.0.dist-info/WHEEL +4 -0
- loopengt-0.1.0.dist-info/entry_points.txt +8 -0
- loopengt-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Main loop executor — the core orchestration engine.
|
|
2
|
+
|
|
3
|
+
Implements the **supervisor orchestration pattern**: reads a ``LoopSpec``,
|
|
4
|
+
initialises agent and step states, and drives steps to completion by
|
|
5
|
+
selecting the next agent/tool at each turn.
|
|
6
|
+
|
|
7
|
+
Patterns supported:
|
|
8
|
+
- Sequential — steps execute in declaration order.
|
|
9
|
+
- Supervisor-Worker — a supervisor agent decides which worker to invoke.
|
|
10
|
+
- Parallel Fan-out — independent steps execute concurrently.
|
|
11
|
+
- Handoff — agents pass context to the next agent directly.
|
|
12
|
+
- Evaluator-Optimizer — an evaluator agent reviews, an optimizer refines.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import anyio
|
|
21
|
+
import structlog
|
|
22
|
+
|
|
23
|
+
from loopengt.core.models.loop_spec import LoopPattern, LoopSpec, StepSpec
|
|
24
|
+
from loopengt.core.models.policy import RetryPolicy
|
|
25
|
+
from loopengt.core.models.state import (
|
|
26
|
+
AgentState,
|
|
27
|
+
LoopState,
|
|
28
|
+
RunStatus,
|
|
29
|
+
StepResult,
|
|
30
|
+
StepState,
|
|
31
|
+
StepStatus,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = structlog.get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExecutionError(Exception):
|
|
38
|
+
"""Raised when a loop execution cannot proceed."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoopExecutor:
|
|
42
|
+
"""Async-first loop executor.
|
|
43
|
+
|
|
44
|
+
Usage::
|
|
45
|
+
|
|
46
|
+
executor = LoopExecutor(spec)
|
|
47
|
+
final_state = await executor.execute()
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, spec: LoopSpec) -> None:
|
|
51
|
+
self._spec = spec
|
|
52
|
+
self._state = self._init_state()
|
|
53
|
+
self._log = logger.bind(loop=spec.name)
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Public API
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async def execute(self) -> LoopState:
|
|
60
|
+
"""Run the loop to completion and return the final state."""
|
|
61
|
+
self._log.info("loop.start", pattern=self._spec.pattern)
|
|
62
|
+
self._state.mark_running()
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
if self._spec.pattern == LoopPattern.SEQUENTIAL:
|
|
66
|
+
await self._run_sequential()
|
|
67
|
+
elif self._spec.pattern == LoopPattern.SUPERVISOR_WORKER:
|
|
68
|
+
await self._run_supervisor_worker()
|
|
69
|
+
elif self._spec.pattern == LoopPattern.PARALLEL_FAN_OUT:
|
|
70
|
+
await self._run_parallel_fan_out()
|
|
71
|
+
elif self._spec.pattern == LoopPattern.HANDOFF:
|
|
72
|
+
await self._run_handoff()
|
|
73
|
+
elif self._spec.pattern == LoopPattern.EVALUATOR_OPTIMIZER:
|
|
74
|
+
await self._run_evaluator_optimizer()
|
|
75
|
+
else:
|
|
76
|
+
# CUSTOM or unknown — fall back to sequential
|
|
77
|
+
self._log.warning(
|
|
78
|
+
"loop.unknown_pattern",
|
|
79
|
+
pattern=self._spec.pattern,
|
|
80
|
+
fallback="sequential",
|
|
81
|
+
)
|
|
82
|
+
await self._run_sequential()
|
|
83
|
+
|
|
84
|
+
self._state.mark_completed()
|
|
85
|
+
self._log.info("loop.completed", run_id=self._state.run_id)
|
|
86
|
+
|
|
87
|
+
except ExecutionError as exc:
|
|
88
|
+
self._state.mark_failed(str(exc))
|
|
89
|
+
self._log.error("loop.failed", error=str(exc))
|
|
90
|
+
except Exception as exc: # noqa: BLE001
|
|
91
|
+
self._state.mark_failed(f"Unexpected error: {exc}")
|
|
92
|
+
self._log.error("loop.unexpected_error", error=str(exc))
|
|
93
|
+
|
|
94
|
+
return self._state
|
|
95
|
+
|
|
96
|
+
async def resume(self, checkpoint_id: str | None = None) -> LoopState:
|
|
97
|
+
"""Resume a paused or failed loop from a checkpoint.
|
|
98
|
+
|
|
99
|
+
If *checkpoint_id* is ``None``, resumes from the last checkpoint.
|
|
100
|
+
"""
|
|
101
|
+
self._log.info("loop.resume", checkpoint_id=checkpoint_id)
|
|
102
|
+
# For now, re-run from the current step index
|
|
103
|
+
self._state.status = RunStatus.RUNNING
|
|
104
|
+
try:
|
|
105
|
+
await self._run_sequential(start_index=self._state.current_step_index)
|
|
106
|
+
self._state.mark_completed()
|
|
107
|
+
except ExecutionError as exc:
|
|
108
|
+
self._state.mark_failed(str(exc))
|
|
109
|
+
return self._state
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Pattern implementations
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async def _run_sequential(self, *, start_index: int = 0) -> None:
|
|
116
|
+
"""Execute steps in declaration order."""
|
|
117
|
+
steps = self._spec.steps[start_index:]
|
|
118
|
+
for i, step in enumerate(steps, start=start_index):
|
|
119
|
+
if self._should_stop():
|
|
120
|
+
self._log.info("loop.stop_condition_met", turn=self._state.turn)
|
|
121
|
+
break
|
|
122
|
+
self._state.current_step_index = i
|
|
123
|
+
await self._execute_step(step)
|
|
124
|
+
|
|
125
|
+
async def _run_supervisor_worker(self) -> None:
|
|
126
|
+
"""Supervisor decides which worker step to run next."""
|
|
127
|
+
# The first agent acts as the supervisor
|
|
128
|
+
supervisor_step = self._spec.steps[0]
|
|
129
|
+
worker_steps = self._spec.steps[1:]
|
|
130
|
+
|
|
131
|
+
while not self._should_stop():
|
|
132
|
+
# Run supervisor to decide next action
|
|
133
|
+
result = await self._execute_step(supervisor_step)
|
|
134
|
+
if result is None:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
# Determine which worker to invoke from supervisor output
|
|
138
|
+
next_worker = self._select_worker(result, worker_steps)
|
|
139
|
+
if next_worker is None:
|
|
140
|
+
self._log.info("supervisor.no_more_workers")
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
await self._execute_step(next_worker)
|
|
144
|
+
|
|
145
|
+
async def _run_parallel_fan_out(self) -> None:
|
|
146
|
+
"""Execute independent steps concurrently using the StepScheduler."""
|
|
147
|
+
from loopengt.core.runtime.scheduler import StepScheduler
|
|
148
|
+
scheduler = StepScheduler(self._spec, self._state)
|
|
149
|
+
|
|
150
|
+
async def _execute_single_step(step: StepSpec) -> None:
|
|
151
|
+
if not self._should_stop():
|
|
152
|
+
await self._execute_step(step)
|
|
153
|
+
|
|
154
|
+
await scheduler.run_parallel(
|
|
155
|
+
_execute_single_step,
|
|
156
|
+
max_concurrency=self._spec.policy.max_concurrency,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def _run_handoff(self) -> None:
|
|
160
|
+
"""Steps hand off context to the next step in sequence using HandoffManager."""
|
|
161
|
+
from loopengt.core.runtime.handoff import HandoffManager
|
|
162
|
+
manager = HandoffManager([agent.name for agent in self._spec.agents])
|
|
163
|
+
|
|
164
|
+
handoff_ctx = None
|
|
165
|
+
for i, step in enumerate(self._spec.steps):
|
|
166
|
+
if self._should_stop():
|
|
167
|
+
break
|
|
168
|
+
self._state.current_step_index = i
|
|
169
|
+
|
|
170
|
+
# Inject handoff context into state context if available
|
|
171
|
+
if handoff_ctx:
|
|
172
|
+
self._state.context["_handoff"] = handoff_ctx.model_dump()
|
|
173
|
+
|
|
174
|
+
result = await self._execute_step(step)
|
|
175
|
+
|
|
176
|
+
# Create handoff payload for the next agent
|
|
177
|
+
if result is not None and i + 1 < len(self._spec.steps):
|
|
178
|
+
next_step = self._spec.steps[i + 1]
|
|
179
|
+
handoff_ctx = manager.create_handoff(
|
|
180
|
+
from_agent=step.agent,
|
|
181
|
+
to_agent=next_step.agent,
|
|
182
|
+
payload={"output": result} if isinstance(result, dict) else {"output": result}
|
|
183
|
+
)
|
|
184
|
+
manager.complete_handoff(handoff_ctx)
|
|
185
|
+
|
|
186
|
+
async def _run_evaluator_optimizer(self) -> None:
|
|
187
|
+
"""Alternate between an evaluator and an optimizer agent."""
|
|
188
|
+
if len(self._spec.steps) < 2:
|
|
189
|
+
raise ExecutionError(
|
|
190
|
+
"evaluator_optimizer pattern requires at least 2 steps"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
evaluator_step = self._spec.steps[0]
|
|
194
|
+
optimizer_step = self._spec.steps[1]
|
|
195
|
+
|
|
196
|
+
while not self._should_stop():
|
|
197
|
+
# Evaluate
|
|
198
|
+
eval_result = await self._execute_step(evaluator_step)
|
|
199
|
+
if eval_result is None:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
# Check if quality is sufficient
|
|
203
|
+
if self._quality_met(eval_result):
|
|
204
|
+
self._log.info("evaluator_optimizer.quality_met")
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
# Optimize
|
|
208
|
+
self._state.context["_evaluation"] = eval_result
|
|
209
|
+
await self._execute_step(optimizer_step)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Step execution
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
async def _execute_step(self, step: StepSpec) -> Any:
|
|
216
|
+
"""Execute a single step with retry logic."""
|
|
217
|
+
step_state = self._state.step_states[step.name]
|
|
218
|
+
retry_policy = self._spec.policy.retry
|
|
219
|
+
agent = self._spec.get_agent(step.agent)
|
|
220
|
+
|
|
221
|
+
if agent is None:
|
|
222
|
+
step_state.mark_failed(f"Agent not found: {step.agent}")
|
|
223
|
+
if self._spec.policy.fail_fast:
|
|
224
|
+
raise ExecutionError(f"Agent not found: {step.agent}")
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
agent_state = self._state.agent_states.get(step.agent)
|
|
228
|
+
self._state.increment_turn()
|
|
229
|
+
|
|
230
|
+
max_attempts = retry_policy.max_retries + 1 if step.allow_retry else 1
|
|
231
|
+
|
|
232
|
+
for attempt in range(max_attempts):
|
|
233
|
+
step_state.mark_running()
|
|
234
|
+
self._log.info(
|
|
235
|
+
"step.start",
|
|
236
|
+
step=step.name,
|
|
237
|
+
agent=step.agent,
|
|
238
|
+
attempt=attempt + 1,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
start_time = time.monotonic()
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
# Build the prompt from template + context
|
|
245
|
+
prompt = self._render_prompt(step)
|
|
246
|
+
|
|
247
|
+
# Execute the agent (stub — calls the agent's LLM or function)
|
|
248
|
+
output = await self._invoke_agent(step, prompt)
|
|
249
|
+
|
|
250
|
+
duration = time.monotonic() - start_time
|
|
251
|
+
result = StepResult(
|
|
252
|
+
output=output,
|
|
253
|
+
duration_seconds=duration,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
step_state.mark_completed(result)
|
|
257
|
+
if agent_state:
|
|
258
|
+
agent_state.record_invocation()
|
|
259
|
+
agent_state.last_output = output
|
|
260
|
+
|
|
261
|
+
self._state.add_to_history(step.name, output)
|
|
262
|
+
self._log.info(
|
|
263
|
+
"step.completed",
|
|
264
|
+
step=step.name,
|
|
265
|
+
duration=f"{duration:.2f}s",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return output
|
|
269
|
+
|
|
270
|
+
except Exception as exc: # noqa: BLE001
|
|
271
|
+
duration = time.monotonic() - start_time
|
|
272
|
+
self._log.warning(
|
|
273
|
+
"step.error",
|
|
274
|
+
step=step.name,
|
|
275
|
+
attempt=attempt + 1,
|
|
276
|
+
error=str(exc),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if agent_state:
|
|
280
|
+
agent_state.record_invocation(error=True)
|
|
281
|
+
|
|
282
|
+
if attempt < max_attempts - 1:
|
|
283
|
+
step_state.mark_retrying()
|
|
284
|
+
delay = retry_policy.compute_delay(attempt)
|
|
285
|
+
self._log.info("step.retry", delay=delay)
|
|
286
|
+
await anyio.sleep(delay)
|
|
287
|
+
else:
|
|
288
|
+
step_state.mark_failed(str(exc))
|
|
289
|
+
if self._spec.policy.fail_fast:
|
|
290
|
+
raise ExecutionError(
|
|
291
|
+
f"Step '{step.name}' failed after "
|
|
292
|
+
f"{max_attempts} attempts: {exc}"
|
|
293
|
+
) from exc
|
|
294
|
+
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
async def _invoke_agent(self, step: StepSpec, prompt: str) -> Any:
|
|
298
|
+
"""Invoke an agent for a step.
|
|
299
|
+
|
|
300
|
+
Uses the openai client (if available) to call the configured
|
|
301
|
+
LLM provider. Falls back to a stub if the dependency is missing.
|
|
302
|
+
"""
|
|
303
|
+
agent = self._spec.get_agent(step.agent)
|
|
304
|
+
agent_name = agent.name if agent else step.agent
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
import os
|
|
308
|
+
from openai import AsyncOpenAI
|
|
309
|
+
except ImportError:
|
|
310
|
+
self._log.warning("openai_not_installed", fallback="stub")
|
|
311
|
+
return {
|
|
312
|
+
"agent": agent_name,
|
|
313
|
+
"step": step.name,
|
|
314
|
+
"prompt_length": len(prompt),
|
|
315
|
+
"response": f"[stub] {agent_name} completed step '{step.name}' (install loopengt[llm] for real execution)",
|
|
316
|
+
"status": "ok",
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
provider_env = os.environ.get("LOOPENGT_LLM_PROVIDER", "openai").lower()
|
|
320
|
+
provider = agent.config.provider.lower() if agent and agent.config.provider else provider_env
|
|
321
|
+
|
|
322
|
+
if provider == "huggingface":
|
|
323
|
+
api_key = os.environ.get("HF_TOKEN")
|
|
324
|
+
base_url = "https://api-inference.huggingface.co/v1/"
|
|
325
|
+
else:
|
|
326
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
327
|
+
base_url = None
|
|
328
|
+
|
|
329
|
+
if not api_key:
|
|
330
|
+
raise ExecutionError(f"API key missing for provider '{provider}'. Please set OPENAI_API_KEY or HF_TOKEN.")
|
|
331
|
+
|
|
332
|
+
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
333
|
+
|
|
334
|
+
model = agent.config.model if agent else "gpt-4o"
|
|
335
|
+
system_prompt = agent.system_prompt if agent else "You are a helpful assistant."
|
|
336
|
+
temperature = agent.config.temperature if agent else 0.7
|
|
337
|
+
max_tokens = agent.config.max_tokens if agent else None
|
|
338
|
+
|
|
339
|
+
kwargs: dict[str, Any] = {
|
|
340
|
+
"model": model,
|
|
341
|
+
"messages": [
|
|
342
|
+
{"role": "system", "content": system_prompt},
|
|
343
|
+
{"role": "user", "content": prompt},
|
|
344
|
+
],
|
|
345
|
+
"temperature": temperature,
|
|
346
|
+
}
|
|
347
|
+
if max_tokens is not None:
|
|
348
|
+
kwargs["max_tokens"] = max_tokens
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
response = await client.chat.completions.create(**kwargs)
|
|
352
|
+
content = response.choices[0].message.content
|
|
353
|
+
return {
|
|
354
|
+
"agent": agent_name,
|
|
355
|
+
"step": step.name,
|
|
356
|
+
"prompt_length": len(prompt),
|
|
357
|
+
"response": content,
|
|
358
|
+
"status": "ok",
|
|
359
|
+
}
|
|
360
|
+
except Exception as e:
|
|
361
|
+
raise ExecutionError(f"LLM request failed: {e}") from e
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# Helpers
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def _init_state(self) -> LoopState:
|
|
368
|
+
"""Build the initial LoopState from the spec."""
|
|
369
|
+
state = LoopState(loop_name=self._spec.name)
|
|
370
|
+
state.context = dict(self._spec.context)
|
|
371
|
+
|
|
372
|
+
# Initialise step states
|
|
373
|
+
for step in self._spec.steps:
|
|
374
|
+
state.step_states[step.name] = StepState(step_name=step.name)
|
|
375
|
+
|
|
376
|
+
# Initialise agent states
|
|
377
|
+
for agent in self._spec.agents:
|
|
378
|
+
state.agent_states[agent.name] = AgentState(agent_name=agent.name)
|
|
379
|
+
|
|
380
|
+
return state
|
|
381
|
+
|
|
382
|
+
def _render_prompt(self, step: StepSpec) -> str:
|
|
383
|
+
"""Render a step's prompt template against the current context."""
|
|
384
|
+
template = step.prompt_template or step.description
|
|
385
|
+
try:
|
|
386
|
+
return template.format(**self._state.context)
|
|
387
|
+
except KeyError:
|
|
388
|
+
# Template has placeholders not yet in context — return raw
|
|
389
|
+
return template
|
|
390
|
+
|
|
391
|
+
def _should_stop(self) -> bool:
|
|
392
|
+
"""Evaluate stop conditions."""
|
|
393
|
+
# Check turn limit
|
|
394
|
+
if self._state.turn >= self._spec.policy.max_turns:
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
# Check explicit stop conditions
|
|
398
|
+
for cond in self._spec.stop_conditions:
|
|
399
|
+
if cond.condition_type.value == "max_turns":
|
|
400
|
+
if isinstance(cond.value, int) and self._state.turn >= cond.value:
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
def _select_worker(
|
|
406
|
+
self, supervisor_output: Any, workers: list[StepSpec]
|
|
407
|
+
) -> StepSpec | None:
|
|
408
|
+
"""Select the next worker step based on supervisor output."""
|
|
409
|
+
import json
|
|
410
|
+
if isinstance(supervisor_output, str):
|
|
411
|
+
try:
|
|
412
|
+
# Naive JSON extraction (strip markdown blocks if any)
|
|
413
|
+
content = supervisor_output.strip()
|
|
414
|
+
if content.startswith("```json"):
|
|
415
|
+
content = content[7:-3].strip()
|
|
416
|
+
supervisor_output = json.loads(content)
|
|
417
|
+
except json.JSONDecodeError:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
if isinstance(supervisor_output, dict):
|
|
421
|
+
next_name = supervisor_output.get("next_step") or supervisor_output.get(
|
|
422
|
+
"next_worker"
|
|
423
|
+
)
|
|
424
|
+
if next_name:
|
|
425
|
+
for w in workers:
|
|
426
|
+
if w.name == next_name:
|
|
427
|
+
return w
|
|
428
|
+
|
|
429
|
+
# Default: pick the first unexecuted worker
|
|
430
|
+
for w in workers:
|
|
431
|
+
step_state = self._state.step_states.get(w.name)
|
|
432
|
+
if step_state and step_state.status == StepStatus.PENDING:
|
|
433
|
+
return w
|
|
434
|
+
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
def _quality_met(self, eval_result: Any) -> bool:
|
|
438
|
+
"""Check if the evaluator's result indicates sufficient quality."""
|
|
439
|
+
import json
|
|
440
|
+
if isinstance(eval_result, str):
|
|
441
|
+
try:
|
|
442
|
+
content = eval_result.strip()
|
|
443
|
+
if content.startswith("```json"):
|
|
444
|
+
content = content[7:-3].strip()
|
|
445
|
+
eval_result = json.loads(content)
|
|
446
|
+
except json.JSONDecodeError:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
if isinstance(eval_result, dict):
|
|
450
|
+
score = eval_result.get("score") or eval_result.get("quality")
|
|
451
|
+
if isinstance(score, (int, float, str)):
|
|
452
|
+
try:
|
|
453
|
+
score = float(score)
|
|
454
|
+
# Check against quality_threshold stop conditions
|
|
455
|
+
for cond in self._spec.stop_conditions:
|
|
456
|
+
if cond.condition_type.value == "quality_threshold":
|
|
457
|
+
threshold = float(cond.value) if cond.value else 0.8
|
|
458
|
+
return score >= threshold
|
|
459
|
+
return score >= 0.8 # default threshold
|
|
460
|
+
except ValueError:
|
|
461
|
+
pass
|
|
462
|
+
return bool(eval_result.get("passed") or eval_result.get("approved"))
|
|
463
|
+
return False
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Agent-to-agent handoff logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HandoffProtocol(StrEnum):
|
|
15
|
+
"""How context is transferred between agents."""
|
|
16
|
+
|
|
17
|
+
DIRECT = "direct"
|
|
18
|
+
VIA_SUPERVISOR = "via_supervisor"
|
|
19
|
+
BROADCAST = "broadcast"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HandoffContext(BaseModel):
|
|
23
|
+
"""Context payload passed between agents during a handoff."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(frozen=True)
|
|
26
|
+
|
|
27
|
+
from_agent: str = Field(..., description="Agent handing off")
|
|
28
|
+
to_agent: str = Field(..., description="Agent receiving")
|
|
29
|
+
protocol: HandoffProtocol = Field(
|
|
30
|
+
default=HandoffProtocol.DIRECT, description="Handoff protocol"
|
|
31
|
+
)
|
|
32
|
+
payload: dict[str, Any] = Field(
|
|
33
|
+
default_factory=dict, description="Data being transferred"
|
|
34
|
+
)
|
|
35
|
+
instructions: str = Field(
|
|
36
|
+
default="", description="Instructions for the receiving agent"
|
|
37
|
+
)
|
|
38
|
+
metadata: dict[str, Any] = Field(
|
|
39
|
+
default_factory=dict, description="Handoff metadata"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HandoffManager:
|
|
44
|
+
"""Manages agent-to-agent handoff within a loop execution.
|
|
45
|
+
|
|
46
|
+
Tracks handoff history and validates agent transitions.
|
|
47
|
+
|
|
48
|
+
Usage::
|
|
49
|
+
|
|
50
|
+
manager = HandoffManager(allowed_agents=["planner", "executor", "reviewer"])
|
|
51
|
+
ctx = manager.create_handoff(
|
|
52
|
+
from_agent="planner",
|
|
53
|
+
to_agent="executor",
|
|
54
|
+
payload={"plan": plan_output},
|
|
55
|
+
)
|
|
56
|
+
# Deliver ctx to the receiving agent
|
|
57
|
+
manager.complete_handoff(ctx)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, allowed_agents: list[str] | None = None) -> None:
|
|
61
|
+
self._allowed = set(allowed_agents) if allowed_agents else None
|
|
62
|
+
self._history: list[HandoffContext] = []
|
|
63
|
+
self._log = logger.bind(component="handoff")
|
|
64
|
+
|
|
65
|
+
def create_handoff(
|
|
66
|
+
self,
|
|
67
|
+
from_agent: str,
|
|
68
|
+
to_agent: str,
|
|
69
|
+
payload: dict[str, Any] | None = None,
|
|
70
|
+
instructions: str = "",
|
|
71
|
+
protocol: HandoffProtocol = HandoffProtocol.DIRECT,
|
|
72
|
+
) -> HandoffContext:
|
|
73
|
+
"""Create a new handoff context.
|
|
74
|
+
|
|
75
|
+
Raises
|
|
76
|
+
------
|
|
77
|
+
ValueError
|
|
78
|
+
If either agent is not in the allowed set.
|
|
79
|
+
"""
|
|
80
|
+
if self._allowed:
|
|
81
|
+
for name in (from_agent, to_agent):
|
|
82
|
+
if name not in self._allowed:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Agent '{name}' not in allowed set: {self._allowed}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
ctx = HandoffContext(
|
|
88
|
+
from_agent=from_agent,
|
|
89
|
+
to_agent=to_agent,
|
|
90
|
+
protocol=protocol,
|
|
91
|
+
payload=payload or {},
|
|
92
|
+
instructions=instructions,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._log.info(
|
|
96
|
+
"handoff.created",
|
|
97
|
+
from_agent=from_agent,
|
|
98
|
+
to_agent=to_agent,
|
|
99
|
+
protocol=protocol,
|
|
100
|
+
)
|
|
101
|
+
return ctx
|
|
102
|
+
|
|
103
|
+
def complete_handoff(self, ctx: HandoffContext) -> None:
|
|
104
|
+
"""Record a completed handoff."""
|
|
105
|
+
self._history.append(ctx)
|
|
106
|
+
self._log.info(
|
|
107
|
+
"handoff.completed",
|
|
108
|
+
from_agent=ctx.from_agent,
|
|
109
|
+
to_agent=ctx.to_agent,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def broadcast(
|
|
113
|
+
self,
|
|
114
|
+
from_agent: str,
|
|
115
|
+
targets: list[str],
|
|
116
|
+
payload: dict[str, Any] | None = None,
|
|
117
|
+
instructions: str = "",
|
|
118
|
+
) -> list[HandoffContext]:
|
|
119
|
+
"""Create handoff contexts for a broadcast to multiple agents."""
|
|
120
|
+
contexts = []
|
|
121
|
+
for target in targets:
|
|
122
|
+
ctx = self.create_handoff(
|
|
123
|
+
from_agent=from_agent,
|
|
124
|
+
to_agent=target,
|
|
125
|
+
payload=payload,
|
|
126
|
+
instructions=instructions,
|
|
127
|
+
protocol=HandoffProtocol.BROADCAST,
|
|
128
|
+
)
|
|
129
|
+
contexts.append(ctx)
|
|
130
|
+
return contexts
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def history(self) -> list[HandoffContext]:
|
|
134
|
+
"""Return the handoff history (read-only copy)."""
|
|
135
|
+
return list(self._history)
|
|
136
|
+
|
|
137
|
+
def last_handoff(self) -> HandoffContext | None:
|
|
138
|
+
"""Return the most recent handoff, or None."""
|
|
139
|
+
return self._history[-1] if self._history else None
|