swarmforge 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.
swarmforge/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Headless multi-agent chat and evaluation framework."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["api", "authoring", "evaluation", "swarm"]
@@ -0,0 +1,5 @@
1
+ """HTTP API integrations."""
2
+
3
+ from .fastapi import create_fastapi_app
4
+
5
+ __all__ = ["create_fastapi_app"]
@@ -0,0 +1,485 @@
1
+ """FastAPI integration for exposing the swarm runtime over HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from typing import Any, AsyncGenerator, Callable, Dict, List
8
+
9
+ from fastapi import Body, FastAPI, HTTPException, Response
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import StreamingResponse
12
+ from pydantic import BaseModel, Field
13
+
14
+ from ..authoring import build_swarm_definition
15
+ from ..evaluation.provider import ModelConfig, OpenAIClientWrapper
16
+ from ..evaluation.swarm import serialize_checkpoints
17
+ from ..swarm import InMemorySessionStore, SessionStore, SwarmSession, process_swarm_stream
18
+ from ..swarm.orchestrator import AgentTurnConfig, StreamAgentIteration
19
+ from ..swarm.tools import FrameworkToolRegistry, ToolRegistry
20
+
21
+ RUN_SWARM_OPENAPI_EXAMPLES = {
22
+ "openrouter": {
23
+ "summary": "Run a swarm turn with OpenRouter",
24
+ "value": {
25
+ "swarm": {
26
+ "id": "support",
27
+ "name": "Support Swarm",
28
+ "nodes": [
29
+ {
30
+ "node_key": "assistant",
31
+ "name": "Assistant",
32
+ "system_prompt": "You are a helpful assistant.",
33
+ "intent": "Handle the full request",
34
+ "capabilities": ["Answer questions"],
35
+ "is_entry_node": True,
36
+ }
37
+ ],
38
+ "edges": [],
39
+ "variables": [],
40
+ },
41
+ "user_input": "Give me a concise answer.",
42
+ "provider": {
43
+ "provider": "openrouter",
44
+ "model": "openrouter/auto",
45
+ "site_url": "https://your-app.example",
46
+ "app_name": "Your App Name",
47
+ },
48
+ },
49
+ },
50
+ "gemini": {
51
+ "summary": "Run a swarm turn with Gemini OpenAI-compat",
52
+ "value": {
53
+ "swarm": {
54
+ "id": "support",
55
+ "name": "Support Swarm",
56
+ "nodes": [
57
+ {
58
+ "node_key": "assistant",
59
+ "name": "Assistant",
60
+ "system_prompt": "You are a helpful assistant.",
61
+ "intent": "Handle the full request",
62
+ "capabilities": ["Answer questions"],
63
+ "is_entry_node": True,
64
+ }
65
+ ],
66
+ "edges": [],
67
+ "variables": [],
68
+ },
69
+ "user_input": "Explain the answer briefly.",
70
+ "provider": {
71
+ "provider": "gemini",
72
+ "model": "gemini-3-flash-preview",
73
+ "default_chat_params": {"reasoning_effort": "low"},
74
+ },
75
+ },
76
+ },
77
+ }
78
+
79
+ SEND_MESSAGE_OPENAPI_EXAMPLES = {
80
+ "openrouter": {
81
+ "summary": "Send a session message with OpenRouter",
82
+ "value": {
83
+ "user_input": "Help me with this request.",
84
+ "provider": {
85
+ "provider": "openrouter",
86
+ "model": "openrouter/auto",
87
+ "site_url": "https://your-app.example",
88
+ "app_name": "Your App Name",
89
+ },
90
+ },
91
+ },
92
+ "gemini": {
93
+ "summary": "Send a session message with Gemini OpenAI-compat",
94
+ "value": {
95
+ "user_input": "Help me with this request.",
96
+ "provider": {
97
+ "provider": "gemini",
98
+ "model": "gemini-3-flash-preview",
99
+ "default_chat_params": {"reasoning_effort": "low"},
100
+ },
101
+ },
102
+ },
103
+ }
104
+
105
+ CREATE_SESSION_OPENAPI_EXAMPLE = {
106
+ "summary": "Create a swarm session",
107
+ "value": {
108
+ "session_id": "session-1",
109
+ "swarm": {
110
+ "id": "single-agent",
111
+ "name": "Single Agent",
112
+ "nodes": [
113
+ {
114
+ "node_key": "assistant",
115
+ "name": "Assistant",
116
+ "system_prompt": "You are a helpful assistant.",
117
+ "intent": "Handle the full request",
118
+ "capabilities": ["Answer questions"],
119
+ "is_entry_node": True,
120
+ }
121
+ ],
122
+ "edges": [],
123
+ "variables": [],
124
+ },
125
+ "global_variables": {},
126
+ },
127
+ }
128
+
129
+
130
+ class ProviderConfigPayload(BaseModel):
131
+ provider: str | None = None
132
+ base_url: str | None = None
133
+ api_key: str | None = None
134
+ model: str | None = None
135
+ temperature: float | None = None
136
+ max_tokens: int | None = None
137
+ site_url: str | None = None
138
+ app_name: str | None = None
139
+ default_headers: Dict[str, str] | None = None
140
+ default_chat_params: Dict[str, Any] | None = None
141
+
142
+ def to_model_config(self, base: ModelConfig | None = None) -> ModelConfig:
143
+ base = base or ModelConfig()
144
+ provider = self.provider or base.provider
145
+ same_provider = provider == base.provider
146
+ return ModelConfig(
147
+ provider=provider,
148
+ base_url=self.base_url if self.base_url is not None else (base.base_url if same_provider else None),
149
+ api_key=self.api_key if self.api_key is not None else (base.api_key if same_provider else None),
150
+ model=self.model or base.model,
151
+ temperature=self.temperature if self.temperature is not None else base.temperature,
152
+ max_tokens=self.max_tokens if self.max_tokens is not None else base.max_tokens,
153
+ site_url=self.site_url,
154
+ app_name=self.app_name,
155
+ default_headers=self.default_headers if self.default_headers is not None else (base.default_headers if same_provider else None),
156
+ default_chat_params=self.default_chat_params if self.default_chat_params is not None else (base.default_chat_params if same_provider else None),
157
+ )
158
+
159
+
160
+ class SwarmVariablePayload(BaseModel):
161
+ key_name: str
162
+ description: str = ""
163
+ reducer_rule: str = "overwrite"
164
+
165
+
166
+ class SwarmEdgePayload(BaseModel):
167
+ source_node_key: str
168
+ target_node_key: str
169
+ handoff_description: str
170
+ required_variables: List[str] = Field(default_factory=list)
171
+
172
+
173
+ class SwarmNodePayload(BaseModel):
174
+ node_key: str
175
+ name: str
176
+ system_prompt: str = ""
177
+ intent: str = ""
178
+ capabilities: List[str] = Field(default_factory=list)
179
+ persona: str = ""
180
+ model_choice: str = ""
181
+ enabled_tools: List[Dict[str, Any]] = Field(default_factory=list)
182
+ behavior_config: Dict[str, Any] = Field(default_factory=dict)
183
+ is_entry_node: bool = False
184
+
185
+
186
+ class SwarmDefinitionPayload(BaseModel):
187
+ id: str = "swarm"
188
+ name: str = "Swarm"
189
+ graph_version: int = 1
190
+ nodes: List[SwarmNodePayload]
191
+ edges: List[SwarmEdgePayload] = Field(default_factory=list)
192
+ variables: List[SwarmVariablePayload] = Field(default_factory=list)
193
+
194
+ def to_runtime(self):
195
+ return build_swarm_definition(
196
+ {
197
+ "nodes": [node.model_dump(mode="python") for node in self.nodes],
198
+ "edges": [edge.model_dump(mode="python") for edge in self.edges],
199
+ "variables": [variable.model_dump(mode="python") for variable in self.variables],
200
+ },
201
+ swarm_id=self.id,
202
+ name=self.name,
203
+ graph_version=self.graph_version,
204
+ )
205
+
206
+
207
+ class CreateSessionRequest(BaseModel):
208
+ session_id: str | None = None
209
+ swarm: SwarmDefinitionPayload
210
+ global_variables: Dict[str, Any] = Field(default_factory=dict)
211
+
212
+
213
+ class SendMessageRequest(BaseModel):
214
+ user_input: str
215
+ provider: ProviderConfigPayload | None = None
216
+
217
+
218
+ class RunSwarmRequest(BaseModel):
219
+ session_id: str | None = None
220
+ swarm: SwarmDefinitionPayload
221
+ user_input: str
222
+ global_variables: Dict[str, Any] = Field(default_factory=dict)
223
+ provider: ProviderConfigPayload | None = None
224
+
225
+
226
+ def _session_payload(session: SwarmSession) -> Dict[str, Any]:
227
+ return {
228
+ "id": session.id,
229
+ "swarm_id": session.swarm.id,
230
+ "swarm_name": session.swarm.name,
231
+ "graph_version": session.swarm.graph_version,
232
+ "current_node_id": session.current_node_id,
233
+ "current_node_key": session.current_node.node_key if session.current_node else None,
234
+ "current_agent": session.current_node.name if session.current_node else None,
235
+ "snapshot": session.snapshot(),
236
+ }
237
+
238
+
239
+ def _sse_event(event: str, payload: Dict[str, Any]) -> str:
240
+ return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
241
+
242
+
243
+ def build_openai_compatible_stream_agent_iteration(client: OpenAIClientWrapper) -> StreamAgentIteration:
244
+ async def _stream_agent_iteration(
245
+ *,
246
+ agent_node,
247
+ contents,
248
+ config: AgentTurnConfig,
249
+ ) -> AsyncGenerator[tuple[str, List[Dict[str, Any]], Any], None]:
250
+ del agent_node
251
+ messages: List[Dict[str, Any]] = []
252
+ if config.system_instruction:
253
+ messages.append({"role": "system", "content": config.system_instruction})
254
+
255
+ for item in contents:
256
+ role = str(item.get("role") or "user").strip()
257
+ if role == "model":
258
+ role = "assistant"
259
+ message: Dict[str, Any] = {"role": role, "content": item.get("content", "")}
260
+ if role == "tool":
261
+ if item.get("tool_call_id"):
262
+ message["tool_call_id"] = item.get("tool_call_id")
263
+ if item.get("name"):
264
+ message["name"] = item.get("name")
265
+ messages.append(message)
266
+
267
+ response = client.chat_completion(messages=messages, tools=config.tools)
268
+ assistant_message = response.choices[0].message
269
+ tool_calls: List[Dict[str, Any]] = []
270
+ if assistant_message.tool_calls:
271
+ for tool_call in assistant_message.tool_calls:
272
+ args = tool_call.function.arguments
273
+ if not isinstance(args, dict):
274
+ args = json.loads(args or "{}")
275
+ tool_calls.append(
276
+ {
277
+ "id": tool_call.id,
278
+ "name": tool_call.function.name,
279
+ "args": args,
280
+ }
281
+ )
282
+ yield assistant_message.content or "", tool_calls, response
283
+
284
+ return _stream_agent_iteration
285
+
286
+
287
+ class _SwarmApiRuntime:
288
+ def __init__(self, store: SessionStore) -> None:
289
+ self.store = store
290
+
291
+ async def create_session(self, *, swarm, session_id: str | None, global_variables: Dict[str, Any]) -> SwarmSession:
292
+ resolved_session_id = session_id or uuid.uuid4().hex
293
+ existing = await self.store.get_session(resolved_session_id)
294
+ if existing is not None:
295
+ raise ValueError(f"Session '{resolved_session_id}' already exists")
296
+ session = SwarmSession(
297
+ id=resolved_session_id,
298
+ swarm=swarm,
299
+ global_variables=dict(global_variables or {}),
300
+ )
301
+ await self.store.save_session(session)
302
+ return session
303
+
304
+ async def get_session(self, session_id: str) -> SwarmSession:
305
+ session = await self.store.get_session(session_id)
306
+ if session is None:
307
+ raise KeyError(session_id)
308
+ return session
309
+
310
+
311
+ def create_fastapi_app(
312
+ *,
313
+ default_model_config: ModelConfig | None = None,
314
+ session_store: SessionStore | None = None,
315
+ stream_agent_iteration_factory: Callable[[ModelConfig], StreamAgentIteration] | None = None,
316
+ tool_registry: ToolRegistry | FrameworkToolRegistry | None = None,
317
+ tool_state: Any = None,
318
+ cors_allow_origins: List[str] | None = None,
319
+ ) -> FastAPI:
320
+ resolved_store = session_store or InMemorySessionStore()
321
+ app = FastAPI(title="SwarmForge API", version="0.1.0")
322
+ app.add_middleware(
323
+ CORSMiddleware,
324
+ allow_origins=cors_allow_origins or ["*"],
325
+ allow_credentials=False,
326
+ allow_methods=["*"],
327
+ allow_headers=["*"],
328
+ )
329
+ app.state.runtime = _SwarmApiRuntime(resolved_store)
330
+ app.state.default_model_config = default_model_config
331
+ app.state.tool_registry = tool_registry
332
+ app.state.tool_state = tool_state
333
+ app.state.stream_agent_iteration_factory = stream_agent_iteration_factory or (
334
+ lambda config: build_openai_compatible_stream_agent_iteration(OpenAIClientWrapper(config))
335
+ )
336
+
337
+ def _resolve_config(provider: ProviderConfigPayload | None) -> ModelConfig:
338
+ base_config = app.state.default_model_config
339
+ return provider.to_model_config(base_config) if provider is not None else (base_config or ModelConfig())
340
+
341
+ async def _run_turn(session: SwarmSession, user_input: str, provider: ProviderConfigPayload | None) -> Dict[str, Any]:
342
+ iteration = app.state.stream_agent_iteration_factory(_resolve_config(provider))
343
+ events: List[Dict[str, Any]] = []
344
+ async for event in process_swarm_stream(
345
+ session,
346
+ user_input,
347
+ store=app.state.runtime.store,
348
+ stream_agent_iteration=iteration,
349
+ tool_registry=app.state.tool_registry,
350
+ tool_state=app.state.tool_state,
351
+ ):
352
+ events.append(event)
353
+ checkpoints = await app.state.runtime.store.list_checkpoints(session.id)
354
+ return {
355
+ "events": events,
356
+ "session": _session_payload(session),
357
+ "checkpoints": serialize_checkpoints(checkpoints),
358
+ }
359
+
360
+ async def _stream_turn(session: SwarmSession, user_input: str, provider: ProviderConfigPayload | None):
361
+ async def event_generator():
362
+ try:
363
+ iteration = app.state.stream_agent_iteration_factory(_resolve_config(provider))
364
+ async for event in process_swarm_stream(
365
+ session,
366
+ user_input,
367
+ store=app.state.runtime.store,
368
+ stream_agent_iteration=iteration,
369
+ tool_registry=app.state.tool_registry,
370
+ tool_state=app.state.tool_state,
371
+ ):
372
+ yield _sse_event(event["event"], event["data"])
373
+ checkpoints = await app.state.runtime.store.list_checkpoints(session.id)
374
+ yield _sse_event("session", _session_payload(session))
375
+ yield _sse_event("checkpoints", {"items": serialize_checkpoints(checkpoints)})
376
+ except Exception as exc:
377
+ yield _sse_event("error", {"message": str(exc)})
378
+
379
+ return StreamingResponse(
380
+ event_generator(),
381
+ media_type="text/event-stream",
382
+ headers={
383
+ "Cache-Control": "no-cache",
384
+ "Connection": "keep-alive",
385
+ "X-Accel-Buffering": "no",
386
+ },
387
+ )
388
+
389
+ @app.get("/")
390
+ async def root() -> Dict[str, str]:
391
+ return {
392
+ "name": "SwarmForge API",
393
+ "status": "ok",
394
+ "docs": "/docs",
395
+ "health": "/health",
396
+ }
397
+
398
+ @app.get("/favicon.ico")
399
+ async def favicon() -> Response:
400
+ return Response(status_code=204)
401
+
402
+ @app.get("/health")
403
+ async def health() -> Dict[str, str]:
404
+ return {"status": "ok"}
405
+
406
+ @app.post("/v1/sessions")
407
+ async def create_session(
408
+ request: CreateSessionRequest = Body(..., openapi_examples={"default": CREATE_SESSION_OPENAPI_EXAMPLE})
409
+ ) -> Dict[str, Any]:
410
+ swarm = request.swarm.to_runtime()
411
+ try:
412
+ session = await app.state.runtime.create_session(
413
+ swarm=swarm,
414
+ session_id=request.session_id,
415
+ global_variables=request.global_variables,
416
+ )
417
+ except ValueError as exc:
418
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
419
+ return {"session": _session_payload(session)}
420
+
421
+ @app.get("/v1/sessions/{session_id}")
422
+ async def get_session(session_id: str) -> Dict[str, Any]:
423
+ try:
424
+ session = await app.state.runtime.get_session(session_id)
425
+ except KeyError as exc:
426
+ raise HTTPException(status_code=404, detail="Session not found") from exc
427
+ checkpoints = await app.state.runtime.store.list_checkpoints(session.id)
428
+ return {
429
+ "session": _session_payload(session),
430
+ "checkpoints": serialize_checkpoints(checkpoints),
431
+ }
432
+
433
+ @app.post("/v1/sessions/{session_id}/messages")
434
+ async def send_message(
435
+ session_id: str,
436
+ request: SendMessageRequest = Body(..., openapi_examples=SEND_MESSAGE_OPENAPI_EXAMPLES),
437
+ ) -> Dict[str, Any]:
438
+ try:
439
+ session = await app.state.runtime.get_session(session_id)
440
+ except KeyError as exc:
441
+ raise HTTPException(status_code=404, detail="Session not found") from exc
442
+ return await _run_turn(session, request.user_input, request.provider)
443
+
444
+ @app.post("/v1/sessions/{session_id}/messages/stream")
445
+ async def stream_message(
446
+ session_id: str,
447
+ request: SendMessageRequest = Body(..., openapi_examples=SEND_MESSAGE_OPENAPI_EXAMPLES),
448
+ ):
449
+ try:
450
+ session = await app.state.runtime.get_session(session_id)
451
+ except KeyError as exc:
452
+ raise HTTPException(status_code=404, detail="Session not found") from exc
453
+ return await _stream_turn(session, request.user_input, request.provider)
454
+
455
+ @app.post("/v1/swarm/run")
456
+ async def run_swarm(
457
+ request: RunSwarmRequest = Body(..., openapi_examples=RUN_SWARM_OPENAPI_EXAMPLES)
458
+ ) -> Dict[str, Any]:
459
+ swarm = request.swarm.to_runtime()
460
+ try:
461
+ session = await app.state.runtime.create_session(
462
+ swarm=swarm,
463
+ session_id=request.session_id,
464
+ global_variables=request.global_variables,
465
+ )
466
+ except ValueError as exc:
467
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
468
+ return await _run_turn(session, request.user_input, request.provider)
469
+
470
+ @app.post("/v1/swarm/run/stream")
471
+ async def stream_swarm_run(
472
+ request: RunSwarmRequest = Body(..., openapi_examples=RUN_SWARM_OPENAPI_EXAMPLES)
473
+ ):
474
+ swarm = request.swarm.to_runtime()
475
+ try:
476
+ session = await app.state.runtime.create_session(
477
+ swarm=swarm,
478
+ session_id=request.session_id,
479
+ global_variables=request.global_variables,
480
+ )
481
+ except ValueError as exc:
482
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
483
+ return await _stream_turn(session, request.user_input, request.provider)
484
+
485
+ return app
@@ -0,0 +1,25 @@
1
+ """Authoring helpers for prompt generation and graph validation."""
2
+
3
+ from .prompts import META_PROMPT_EDGE, META_PROMPT_NODE, META_PROMPT_SWARM
4
+ from .validators import (
5
+ VALID_REDUCER_RULES,
6
+ apply_swarm_graph_payload,
7
+ build_generated_variables_from_swarm_payload,
8
+ build_swarm_definition,
9
+ validate_generated_edge_payload,
10
+ validate_generated_swarm_payload,
11
+ validate_swarm_definition,
12
+ )
13
+
14
+ __all__ = [
15
+ "META_PROMPT_EDGE",
16
+ "META_PROMPT_NODE",
17
+ "META_PROMPT_SWARM",
18
+ "VALID_REDUCER_RULES",
19
+ "apply_swarm_graph_payload",
20
+ "build_generated_variables_from_swarm_payload",
21
+ "build_swarm_definition",
22
+ "validate_generated_edge_payload",
23
+ "validate_generated_swarm_payload",
24
+ "validate_swarm_definition",
25
+ ]
@@ -0,0 +1,165 @@
1
+ """Prompt templates for swarm authoring workflows."""
2
+
3
+ META_PROMPT_NODE = """
4
+ You are the SwarmForge Node Authoring Skill.
5
+
6
+ Your job is to write the production-ready `system_prompt` text for one `swarmforge.swarm.SwarmNode`.
7
+
8
+ Framework context:
9
+ - This prompt becomes the value of the node's `system_prompt` field.
10
+ - The runtime injects routing rules, handoff guidance, visible global variables, tool schemas, and behavior config separately.
11
+ - Do not hardcode graph structure, downstream agents, transfer logic, or orchestration internals into the prompt.
12
+ - If tools exist, the prompt may describe when to use them, but it must not invent tools that were not provided.
13
+
14
+ Node inputs:
15
+ - Agent Name: {node_name}
16
+ - User Need / Intent: {user_intent}
17
+ - Capabilities: {capabilities_summary}
18
+ - Persona Guidance: {persona_summary}
19
+
20
+ Write a system prompt that:
21
+ - defines the node's job, scope boundaries, and success criteria
22
+ - explains how the listed capabilities shape the node's work
23
+ - tells the node what facts it should gather before acting when information is incomplete
24
+ - keeps the node task-focused, concrete, and usable in a real runtime
25
+ - uses persona guidance for tone and collaboration style only
26
+ - avoids delegation policy, handoff conditions, routing rules, or references to `transfer_to_agent`
27
+ - does not mention JSON, schemas, graph nodes, or framework implementation details
28
+
29
+ Output rules:
30
+ - Return only the prompt text
31
+ - Do not wrap it in JSON, markdown, or code fences
32
+ """.strip()
33
+
34
+
35
+ META_PROMPT_EDGE = """
36
+ You are the SwarmForge Edge Authoring Skill.
37
+
38
+ Your job is to generate one SwarmForge edge payload describing when a source node should hand off to a target node.
39
+
40
+ Framework context:
41
+ - The result will be used as a `SwarmEdge` authoring payload.
42
+ - `handoff_description` becomes runtime guidance for when a transfer is valid.
43
+ - `required_variables` must contain only the minimum shared facts the source node should gather before transfer.
44
+
45
+ Route inputs:
46
+ - Source Agent: {source_name}
47
+ - Source Intent: {source_intent}
48
+ - Target Agent: {target_name}
49
+ - Target Intent: {target_intent}
50
+
51
+ Generate:
52
+ 1. `handoff_description`
53
+ One sentence that tells the source node when the user's request should be routed to the target node.
54
+ 2. `required_variables`
55
+ A list of 0 to 3 variable keys the source node should collect before the transfer.
56
+
57
+ Rules for `handoff_description`:
58
+ - It must require the source node to identify or confirm the user's intent before transfer.
59
+ - It must transfer only when the request clearly matches the target node's scope.
60
+ - It must not encourage handoff based on a weak keyword match.
61
+ - It must describe the business condition for transfer, not implementation details.
62
+
63
+ Rules for `required_variables`:
64
+ - Use short snake_case keys.
65
+ - Include only facts that the target node genuinely needs.
66
+ - Return an empty array if no shared variable is required.
67
+ - Do not invent internal-only state or transport fields.
68
+
69
+ Return only valid JSON matching this schema:
70
+ {{"handoff_description": "...", "required_variables": ["..."]}}
71
+ """.strip()
72
+
73
+
74
+ META_PROMPT_SWARM = """
75
+ You are the SwarmForge Swarm Authoring Skill.
76
+
77
+ Your job is to convert a user request into a runnable SwarmForge graph payload that can be passed directly to `build_swarm_definition(...)`.
78
+
79
+ User request:
80
+ {global_intent}
81
+
82
+ Framework schema:
83
+ - Return one JSON object with `nodes`, `edges`, and optionally `variables`.
84
+ - The payload must match the SwarmForge authoring model used by `swarmforge.authoring.build_swarm_definition`.
85
+ - Favor the smallest graph that can solve the request.
86
+ - Use a single node when one agent can handle the task. Introduce a triage/router node only when the request clearly requires multiple specialized agents or intent-based routing.
87
+
88
+ Each node may contain:
89
+ - `node_key`: short stable snake_case key
90
+ - `name`: human-readable unique display name
91
+ - `intent`: clear business responsibility for the node
92
+ - `capabilities`: array of 2 to 5 concrete capabilities
93
+ - `persona`: optional tone/style guidance, or an empty string
94
+ - `system_prompt`: production-ready prompt text for this node
95
+ - `model_choice`: optional model override, or an empty string
96
+ - `enabled_tools`: array of tool definitions, usually `[]` unless the request clearly requires known tools
97
+ - `behavior_config`: object, usually `{}` unless the request clearly needs non-default behavior
98
+ - `is_entry_node`: boolean
99
+
100
+ Each edge may contain:
101
+ - `source_node_key`
102
+ - `target_node_key`
103
+ - `handoff_description`
104
+ - `required_variables`
105
+
106
+ Each top-level variable may contain:
107
+ - `key_name`
108
+ - `description`
109
+ - `reducer_rule` where the value is `overwrite`, `append`, or `keep_first`
110
+
111
+ Generation rules:
112
+ - Produce between 1 and 6 nodes.
113
+ - Mark exactly one node as `is_entry_node=true`.
114
+ - Every node must have a concrete and non-overlapping responsibility.
115
+ - Every `intent` must be specific enough to drive routing and evaluation.
116
+ - Every `system_prompt` must be ready to run and must stay inside the node's own scope.
117
+ - Do not put graph-wide routing rules inside node `system_prompt` values.
118
+ - Every edge must reference existing node keys and may not self-target.
119
+ - Only create edges for realistic handoffs.
120
+ - `required_variables` should be the minimum facts that the target node needs from the source node.
121
+ - Only create top-level `variables` for facts that are shared across nodes or required by handoffs.
122
+ - Use `overwrite` unless there is a clear reason to preserve the first value or accumulate a list.
123
+ - If tools are not explicitly available from the request context, use `enabled_tools: []`.
124
+ - If no custom behavior is necessary, use `behavior_config: {}`.
125
+ - If no model override is necessary, use `model_choice: ""`.
126
+ - Do not invent framework fields outside the schema above.
127
+
128
+ Return only valid JSON with this shape:
129
+ {
130
+ "nodes": [
131
+ {
132
+ "node_key": "...",
133
+ "name": "...",
134
+ "intent": "...",
135
+ "capabilities": ["..."],
136
+ "persona": "...",
137
+ "system_prompt": "...",
138
+ "model_choice": "",
139
+ "enabled_tools": [],
140
+ "behavior_config": {},
141
+ "is_entry_node": true
142
+ }
143
+ ],
144
+ "edges": [
145
+ {
146
+ "source_node_key": "...",
147
+ "target_node_key": "...",
148
+ "handoff_description": "...",
149
+ "required_variables": ["..."]
150
+ }
151
+ ],
152
+ "variables": [
153
+ {
154
+ "key_name": "...",
155
+ "description": "...",
156
+ "reducer_rule": "overwrite"
157
+ }
158
+ ]
159
+ }
160
+
161
+ Output rules:
162
+ - Return raw JSON only
163
+ - Do not use markdown fences
164
+ - Do not include explanations before or after the JSON
165
+ """.strip()