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.
Files changed (87) hide show
  1. loopengt/__init__.py +31 -0
  2. loopengt/adapters/__init__.py +1 -0
  3. loopengt/adapters/antigravity/__init__.py +1 -0
  4. loopengt/adapters/antigravity/adapter.py +55 -0
  5. loopengt/adapters/antigravity/commands.py +21 -0
  6. loopengt/adapters/base.py +51 -0
  7. loopengt/adapters/claude_code/__init__.py +1 -0
  8. loopengt/adapters/claude_code/adapter.py +55 -0
  9. loopengt/adapters/claude_code/commands.py +16 -0
  10. loopengt/adapters/codex/__init__.py +1 -0
  11. loopengt/adapters/codex/adapter.py +52 -0
  12. loopengt/adapters/codex/commands.py +16 -0
  13. loopengt/adapters/cursor/__init__.py +1 -0
  14. loopengt/adapters/cursor/adapter.py +56 -0
  15. loopengt/adapters/cursor/commands.py +29 -0
  16. loopengt/adapters/generic/__init__.py +1 -0
  17. loopengt/adapters/generic/terminal.py +82 -0
  18. loopengt/cli/__init__.py +1 -0
  19. loopengt/cli/commands/__init__.py +1 -0
  20. loopengt/cli/commands/design.py +171 -0
  21. loopengt/cli/commands/doctor.py +110 -0
  22. loopengt/cli/commands/eval.py +105 -0
  23. loopengt/cli/commands/init.py +131 -0
  24. loopengt/cli/commands/mcp_serve.py +57 -0
  25. loopengt/cli/commands/run.py +99 -0
  26. loopengt/cli/commands/template.py +145 -0
  27. loopengt/cli/commands/trace.py +114 -0
  28. loopengt/cli/formatters.py +125 -0
  29. loopengt/cli/main.py +66 -0
  30. loopengt/core/__init__.py +1 -0
  31. loopengt/core/evals/__init__.py +1 -0
  32. loopengt/core/evals/judges.py +216 -0
  33. loopengt/core/evals/metrics.py +119 -0
  34. loopengt/core/evals/regression.py +157 -0
  35. loopengt/core/memory/__init__.py +1 -0
  36. loopengt/core/memory/retrieval.py +124 -0
  37. loopengt/core/memory/store.py +184 -0
  38. loopengt/core/memory/summarizer.py +97 -0
  39. loopengt/core/models/__init__.py +43 -0
  40. loopengt/core/models/agent.py +126 -0
  41. loopengt/core/models/loop_spec.py +251 -0
  42. loopengt/core/models/policy.py +131 -0
  43. loopengt/core/models/state.py +271 -0
  44. loopengt/core/models/tool.py +105 -0
  45. loopengt/core/runtime/__init__.py +1 -0
  46. loopengt/core/runtime/checkpoint.py +152 -0
  47. loopengt/core/runtime/executor.py +463 -0
  48. loopengt/core/runtime/handoff.py +139 -0
  49. loopengt/core/runtime/scheduler.py +168 -0
  50. loopengt/core/tracing/__init__.py +1 -0
  51. loopengt/core/tracing/events.py +95 -0
  52. loopengt/core/tracing/exporters.py +158 -0
  53. loopengt/core/tracing/store.py +202 -0
  54. loopengt/mcp/__init__.py +1 -0
  55. loopengt/mcp/client/__init__.py +1 -0
  56. loopengt/mcp/client/manager.py +118 -0
  57. loopengt/mcp/client/tools.py +107 -0
  58. loopengt/mcp/server/__init__.py +1 -0
  59. loopengt/mcp/server/prompts.py +82 -0
  60. loopengt/mcp/server/resources.py +75 -0
  61. loopengt/mcp/server/server.py +50 -0
  62. loopengt/mcp/server/tools.py +214 -0
  63. loopengt/mcp/shared/__init__.py +1 -0
  64. loopengt/mcp/shared/schemas.py +91 -0
  65. loopengt/plugins/__init__.py +1 -0
  66. loopengt/plugins/base.py +90 -0
  67. loopengt/plugins/loader.py +130 -0
  68. loopengt/plugins/manifest.py +70 -0
  69. loopengt/plugins/registry.py +146 -0
  70. loopengt/prompts/LOOPENGT.md +60 -0
  71. loopengt/prompts/__init__.py +1 -0
  72. loopengt/storage/__init__.py +1 -0
  73. loopengt/storage/jsonl.py +84 -0
  74. loopengt/storage/sqlite.py +102 -0
  75. loopengt/templates/__init__.py +1 -0
  76. loopengt/templates/builtins/handoff_loop/LOOPENGS.md +10 -0
  77. loopengt/templates/builtins/planner_executor/LOOPENGS.md +29 -0
  78. loopengt/templates/builtins/research_architect/LOOPENGS.md +17 -0
  79. loopengt/templates/builtins/reviewer_retry/LOOPENGS.md +29 -0
  80. loopengt/templates/builtins/supervisor_workers/LOOPENGS.md +29 -0
  81. loopengt/templates/loader.py +38 -0
  82. loopengt/templates/registry.py +85 -0
  83. loopengt-0.1.0.dist-info/METADATA +275 -0
  84. loopengt-0.1.0.dist-info/RECORD +87 -0
  85. loopengt-0.1.0.dist-info/WHEEL +4 -0
  86. loopengt-0.1.0.dist-info/entry_points.txt +8 -0
  87. 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