spriteforge 0.1.1__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 (218) hide show
  1. spriteforge/__init__.py +1 -0
  2. spriteforge/__main__.py +3 -0
  3. spriteforge/agents/__init__.py +0 -0
  4. spriteforge/agents/assets.py +109 -0
  5. spriteforge/agents/cancellation.py +12 -0
  6. spriteforge/agents/director.py +50 -0
  7. spriteforge/agents/models.py +63 -0
  8. spriteforge/agents/roles/art-director.md +15 -0
  9. spriteforge/agents/roles/art-reviewer.md +14 -0
  10. spriteforge/agents/roles/director.md +14 -0
  11. spriteforge/agents/roles/game-designer.md +15 -0
  12. spriteforge/agents/roles/level-tile-designer.md +16 -0
  13. spriteforge/agents/roles/librarian.md +13 -0
  14. spriteforge/agents/roles/menu-designer.md +15 -0
  15. spriteforge/agents/roles/pixel-artist.md +14 -0
  16. spriteforge/agents/roles/programmer.md +14 -0
  17. spriteforge/agents/roles/qa-playtester.md +13 -0
  18. spriteforge/agents/roles/sound-director.md +14 -0
  19. spriteforge/agents/roles.py +39 -0
  20. spriteforge/agents/router.py +233 -0
  21. spriteforge/agents/router_eval.py +145 -0
  22. spriteforge/agents/routing_state.py +133 -0
  23. spriteforge/agents/runtime.py +430 -0
  24. spriteforge/agents/workflow.py +389 -0
  25. spriteforge/app/__init__.py +0 -0
  26. spriteforge/app/agents_view.py +248 -0
  27. spriteforge/app/chat.py +114 -0
  28. spriteforge/app/extensions.py +115 -0
  29. spriteforge/app/index.html +279 -0
  30. spriteforge/app/local_only.py +49 -0
  31. spriteforge/app/server.py +358 -0
  32. spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
  33. spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
  34. spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
  35. spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
  36. spriteforge/app/static/index.html +13 -0
  37. spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
  38. spriteforge/app/studio.py +278 -0
  39. spriteforge/app/workers.py +73 -0
  40. spriteforge/cli.py +241 -0
  41. spriteforge/config.py +193 -0
  42. spriteforge/config.yaml +157 -0
  43. spriteforge/core/__init__.py +0 -0
  44. spriteforge/core/acting.py +16 -0
  45. spriteforge/core/cost.py +236 -0
  46. spriteforge/core/credentials.py +24 -0
  47. spriteforge/core/demo.py +79 -0
  48. spriteforge/core/forms.py +20 -0
  49. spriteforge/core/gates.py +175 -0
  50. spriteforge/core/security.py +42 -0
  51. spriteforge/core/state.py +222 -0
  52. spriteforge/core/team.py +15 -0
  53. spriteforge/core/usage.py +68 -0
  54. spriteforge/game/__init__.py +0 -0
  55. spriteforge/game/actors.py +377 -0
  56. spriteforge/game/agent_tools.py +178 -0
  57. spriteforge/game/art.py +2097 -0
  58. spriteforge/game/art_checks.py +190 -0
  59. spriteforge/game/art_review.py +518 -0
  60. spriteforge/game/build.py +134 -0
  61. spriteforge/game/bundle.py +1178 -0
  62. spriteforge/game/cli.py +119 -0
  63. spriteforge/game/codegen.py +433 -0
  64. spriteforge/game/codeinfo.py +154 -0
  65. spriteforge/game/dbsource.py +137 -0
  66. spriteforge/game/design.py +342 -0
  67. spriteforge/game/export.py +62 -0
  68. spriteforge/game/gamedb.py +191 -0
  69. spriteforge/game/image_cache.py +61 -0
  70. spriteforge/game/lessons.py +259 -0
  71. spriteforge/game/level_gen.py +118 -0
  72. spriteforge/game/level_preview.py +97 -0
  73. spriteforge/game/levels.py +934 -0
  74. spriteforge/game/loops.py +38 -0
  75. spriteforge/game/menus.py +712 -0
  76. spriteforge/game/mock_game.js +109 -0
  77. spriteforge/game/mock_platformer.js +58 -0
  78. spriteforge/game/motion.py +881 -0
  79. spriteforge/game/pipeline.py +1768 -0
  80. spriteforge/game/plan.py +502 -0
  81. spriteforge/game/playtest.py +318 -0
  82. spriteforge/game/qa.py +664 -0
  83. spriteforge/game/qa_checks.py +223 -0
  84. spriteforge/game/qa_runtime.py +565 -0
  85. spriteforge/game/reach.py +807 -0
  86. spriteforge/game/record.py +138 -0
  87. spriteforge/game/research.py +262 -0
  88. spriteforge/game/revision.py +225 -0
  89. spriteforge/game/sdk/API.md +104 -0
  90. spriteforge/game/sdk/FLOW.md +19 -0
  91. spriteforge/game/sdk/LEVELS.md +65 -0
  92. spriteforge/game/sdk/sf.js +418 -0
  93. spriteforge/game/sdk/sfflow.js +442 -0
  94. spriteforge/game/sdk/sflevel.js +683 -0
  95. spriteforge/game/sdk/sfsound-editor.js +225 -0
  96. spriteforge/game/sdk/sfsound.js +260 -0
  97. spriteforge/game/sdk/sounds.html +67 -0
  98. spriteforge/game/sounds.py +793 -0
  99. spriteforge/game/story.py +188 -0
  100. spriteforge/game/structure.py +98 -0
  101. spriteforge/game/team_reports.py +394 -0
  102. spriteforge/game/tilesets.py +70 -0
  103. spriteforge/game/title_art.py +186 -0
  104. spriteforge/genres/__init__.py +0 -0
  105. spriteforge/library/__init__.py +15 -0
  106. spriteforge/library/cli.py +126 -0
  107. spriteforge/library/describe.py +242 -0
  108. spriteforge/library/looks.py +154 -0
  109. spriteforge/library/models.py +79 -0
  110. spriteforge/library/store.py +422 -0
  111. spriteforge/library/tilesets/README.md +32 -0
  112. spriteforge/library/tilesets/gandalf-seasons.json +111 -0
  113. spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
  114. spriteforge/mcp/__init__.py +0 -0
  115. spriteforge/mcp/cli.py +58 -0
  116. spriteforge/mcp/client.py +57 -0
  117. spriteforge/mcp/filesystem_read.py +32 -0
  118. spriteforge/mcp/gamedb.py +56 -0
  119. spriteforge/mcp/library.py +179 -0
  120. spriteforge/mcp/memory.py +37 -0
  121. spriteforge/mcp/pixel_tools.py +145 -0
  122. spriteforge/mcp/registry.py +145 -0
  123. spriteforge/mcp/service.py +224 -0
  124. spriteforge/mcp/templates/context7.json +6 -0
  125. spriteforge/mcp/templates/fetch.json +6 -0
  126. spriteforge/mcp/templates/filesystem-read.json +6 -0
  127. spriteforge/mcp/templates/pixel-tools.json +6 -0
  128. spriteforge/memory/README.md +10 -0
  129. spriteforge/memory/__init__.py +0 -0
  130. spriteforge/memory/cli.py +131 -0
  131. spriteforge/memory/okf/__init__.py +1 -0
  132. spriteforge/memory/okf/catalog.py +331 -0
  133. spriteforge/memory/okf/codec.py +63 -0
  134. spriteforge/memory/okf/git.py +104 -0
  135. spriteforge/memory/okf/indexes.py +50 -0
  136. spriteforge/memory/okf/models.py +114 -0
  137. spriteforge/memory/okf/search.py +53 -0
  138. spriteforge/memory/okf/seed.py +81 -0
  139. spriteforge/memory/okf/seeds/genres.yaml +121 -0
  140. spriteforge/memory/okf/seeds/other.yaml +150 -0
  141. spriteforge/memory/okf/store.py +402 -0
  142. spriteforge/memory/okf/validate.py +115 -0
  143. spriteforge/pixel/__init__.py +17 -0
  144. spriteforge/pixel/atlas.py +110 -0
  145. spriteforge/pixel/grid.py +174 -0
  146. spriteforge/pixel/palette.py +156 -0
  147. spriteforge/pixel/palettes/dungeon.json +9 -0
  148. spriteforge/pixel/postprocess.py +170 -0
  149. spriteforge/pixel/preview.py +40 -0
  150. spriteforge/pixel/schemas.py +139 -0
  151. spriteforge/pixel/source.py +237 -0
  152. spriteforge/pixel/terrain.py +48 -0
  153. spriteforge/pixel/validate.py +138 -0
  154. spriteforge/prompts/anim_frame.txt +1 -0
  155. spriteforge/prompts/character.txt +1 -0
  156. spriteforge/prompts/item.txt +1 -0
  157. spriteforge/prompts/tile.txt +1 -0
  158. spriteforge/prompts/ui.txt +1 -0
  159. spriteforge/providers/__init__.py +0 -0
  160. spriteforge/providers/base.py +97 -0
  161. spriteforge/providers/http.py +38 -0
  162. spriteforge/providers/mock.py +111 -0
  163. spriteforge/providers/muse_image.py +60 -0
  164. spriteforge/providers/muse_search.py +91 -0
  165. spriteforge/providers/muse_spark.py +66 -0
  166. spriteforge/skills/__init__.py +0 -0
  167. spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
  168. spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
  169. spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
  170. spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
  171. spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
  172. spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
  173. spriteforge/skills/builtin/level-design/SKILL.md +169 -0
  174. spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
  175. spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
  176. spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
  177. spriteforge/skills/builtin/platformer/SKILL.md +153 -0
  178. spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
  179. spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
  180. spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
  181. spriteforge/skills/catalog.py +200 -0
  182. spriteforge/skills/cli.py +41 -0
  183. spriteforge/stages/__init__.py +0 -0
  184. spriteforge/stages/asset_context.py +301 -0
  185. spriteforge/stages/asset_models.py +192 -0
  186. spriteforge/stages/asset_render.py +366 -0
  187. spriteforge/stages/asset_review.py +140 -0
  188. spriteforge/stages/asset_store.py +94 -0
  189. spriteforge/stages/chat_changes.py +236 -0
  190. spriteforge/stages/concept.py +42 -0
  191. spriteforge/stages/game.py +1699 -0
  192. spriteforge/stages/game_confirm.py +112 -0
  193. spriteforge/stages/game_final.py +220 -0
  194. spriteforge/stages/game_record.py +246 -0
  195. spriteforge/stages/gameplay.py +218 -0
  196. spriteforge/stages/intake.py +372 -0
  197. spriteforge/stages/preview_assets.py +212 -0
  198. spriteforge/stages/preview_models.py +167 -0
  199. spriteforge/stages/preview_render.py +169 -0
  200. spriteforge/stages/preview_repair.py +61 -0
  201. spriteforge/stages/preview_review.py +169 -0
  202. spriteforge/stages/production.py +170 -0
  203. spriteforge/stages/references.py +105 -0
  204. spriteforge/stages/rewind.py +128 -0
  205. spriteforge/stages/workflow.py +440 -0
  206. spriteforge/templates/phaser/audit.js +45 -0
  207. spriteforge/templates/phaser/font.png +0 -0
  208. spriteforge/templates/phaser/font.xml +1 -0
  209. spriteforge/templates/phaser/preview.html +9 -0
  210. spriteforge/templates/phaser/room.js +49 -0
  211. spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
  212. spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
  213. spriteforge-0.1.1.dist-info/METADATA +364 -0
  214. spriteforge-0.1.1.dist-info/RECORD +218 -0
  215. spriteforge-0.1.1.dist-info/WHEEL +5 -0
  216. spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
  217. spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
  218. spriteforge-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,430 @@
1
+ """Bounded provider/tool loop; every cross-agent result is a persisted typed artifact."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import threading
7
+ from collections.abc import Callable
8
+ from contextvars import copy_context
9
+ from dataclasses import dataclass
10
+ from uuid import uuid4
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
13
+
14
+ from spriteforge.config import RouterConfig, load_config
15
+ from spriteforge.core.security import redact
16
+ from spriteforge.core.state import Artifact, Store
17
+ from spriteforge.mcp.service import ConfirmationRequired, MCPService
18
+ from spriteforge.memory.okf.store import KnowledgeStore
19
+ from spriteforge.providers.base import ChatRequest, LLMProvider, Message
20
+ from spriteforge.skills.catalog import SkillCatalog
21
+
22
+ from .cancellation import cancelled, check_active
23
+ from .roles import Role
24
+ from .router import build_catalog, token_units
25
+ from .routing_state import RoutingStore
26
+
27
+
28
+ class AgentTask(BaseModel):
29
+ id: str | None = None
30
+ goal: str
31
+ inputs: list[str] = Field(default_factory=list)
32
+ memory_budget: int = Field(default=768, ge=0)
33
+
34
+
35
+ class ToolCall(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+ id: str
38
+ name: str
39
+ arguments: dict = Field(default_factory=dict)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Tool:
44
+ arguments: type[BaseModel]
45
+ result: type[BaseModel]
46
+ handler: Callable
47
+ description: str = ""
48
+
49
+ def spec(self, name: str) -> dict:
50
+ return {
51
+ "type": "function",
52
+ "function": {
53
+ "name": name,
54
+ "description": self.description,
55
+ "parameters": self.arguments.model_json_schema(),
56
+ },
57
+ }
58
+
59
+
60
+ async def invoke(function, *args):
61
+ check_active()
62
+ if inspect.iscoroutinefunction(function):
63
+ return await function(*args)
64
+ # Daemon threads do not make asyncio.run wait for a timed-out synchronous call.
65
+ # Adapters must check_active() before publishing side effects after a provider call.
66
+ loop = asyncio.get_running_loop()
67
+ future = loop.create_future()
68
+ context = copy_context()
69
+
70
+ def finish(value, error):
71
+ if not future.done():
72
+ if error is None:
73
+ future.set_result(value)
74
+ else:
75
+ future.set_exception(error)
76
+
77
+ def work():
78
+ try:
79
+ value, error = context.run(function, *args), None
80
+ except Exception as exc: # noqa: BLE001 -- forward tool/provider errors to the caller
81
+ value, error = None, exc
82
+ if not loop.is_closed():
83
+ try:
84
+ loop.call_soon_threadsafe(finish, value, error)
85
+ except RuntimeError:
86
+ pass # The caller already timed out and closed its event loop.
87
+
88
+ threading.Thread(target=work, daemon=True).start()
89
+ result = await future
90
+ check_active()
91
+ return await result if inspect.isawaitable(result) else result
92
+
93
+
94
+ class AgentRuntime:
95
+ def __init__(
96
+ self,
97
+ store: Store,
98
+ run_id: str,
99
+ memory: KnowledgeStore,
100
+ provider: LLMProvider | dict[str, LLMProvider],
101
+ tools: dict[str, Tool] | None = None,
102
+ skills: dict[str, str] | None = None,
103
+ mcp: MCPService | None = None,
104
+ router_config: RouterConfig | None = None,
105
+ router_provider: LLMProvider | None = None,
106
+ ):
107
+ self.store, self.run_id, self.memory, self.provider = store, run_id, memory, provider
108
+ self.tools, self.skills = tools or {}, skills or {}
109
+ self.catalog = SkillCatalog(store.path.parent)
110
+ self.mcp = mcp or MCPService(store)
111
+ settings = store.get_run(run_id).config
112
+ self.router_config = router_config or RouterConfig.model_validate(
113
+ settings.get("settings", settings).get("router", load_config().router.model_dump())
114
+ )
115
+ self.router_provider = router_provider
116
+ with store.connect() as db:
117
+ db.execute(
118
+ "CREATE TABLE IF NOT EXISTS agent_checkpoints (run_id TEXT, task_id TEXT, body TEXT, PRIMARY KEY(run_id,task_id))"
119
+ )
120
+
121
+ async def run(
122
+ self, role: Role, task: AgentTask, schema: type[BaseModel], *, timeout: float = 60
123
+ ) -> Artifact:
124
+ signal = threading.Event()
125
+ task = task.model_copy(update={"id": task.id or uuid4().hex})
126
+ token = cancelled.set(signal)
127
+ try:
128
+ return await asyncio.wait_for(self._loop(role, task, schema), timeout)
129
+ except ConfirmationRequired:
130
+ self.store.append_event(self.run_id, role.name, "agent.paused", {"task_id": task.id})
131
+ raise
132
+ except Exception as exc:
133
+ RoutingStore(self.store, self.run_id, task.id).outcome("failed")
134
+ self.store.append_event(
135
+ self.run_id, role.name, "agent.failed", {"error": type(exc).__name__}
136
+ )
137
+ raise
138
+ finally:
139
+ signal.set()
140
+ cancelled.reset(token)
141
+
142
+ async def _loop(self, role, task, schema):
143
+ if task.id:
144
+ prior = next(
145
+ (a for a in self.store.artifacts(self.run_id) if a.name == f"agent/{task.id}.json"),
146
+ None,
147
+ )
148
+ if prior:
149
+ schema.model_validate_json(prior.data)
150
+ RoutingStore(self.store, self.run_id, task.id).outcome("completed")
151
+ return prior
152
+ # Redact before retrieval or model dispatch, including Librarian inputs.
153
+ snippets = await invoke(
154
+ self.memory.retrieve, redact(task.goal), role.memory_scopes, task.memory_budget
155
+ )
156
+ self.store.append_event(
157
+ self.run_id,
158
+ role.name,
159
+ "agent.memory",
160
+ {"ids": [s.id for s in snippets], "scopes": role.memory_scopes},
161
+ )
162
+ artifacts = {a.id: a for a in self.store.artifacts(self.run_id)}
163
+ inputs = []
164
+ for ident in task.inputs:
165
+ artifact = artifacts[ident] # Only explicit artifacts from this run may be passed.
166
+ inputs.append(
167
+ {
168
+ "id": ident,
169
+ "name": artifact.name,
170
+ "mime": artifact.mime,
171
+ "content": artifact.data.decode("utf-8")
172
+ if artifact.mime == "application/json"
173
+ else None,
174
+ }
175
+ )
176
+ # Revalidate mutable Role objects before using their permissions.
177
+ role = Role.model_validate(role.model_dump())
178
+ tools = {name: self.tools[name] for name in role.allowed_tools if name in self.tools}
179
+ mcp_tools = self.mcp.registry.allowed(role)
180
+ provider = self.provider[role.model] if isinstance(self.provider, dict) else self.provider
181
+ catalog = build_catalog(role, self.catalog, self.skills, tools, mcp_tools)
182
+ routing = RoutingStore(self.store, self.run_id, task.id)
183
+ routed = await routing.prepare(
184
+ role,
185
+ task.goal,
186
+ catalog,
187
+ self.router_config,
188
+ self.router_provider or provider,
189
+ invoke,
190
+ self.catalog,
191
+ self.skills,
192
+ )
193
+ selected = routed.decision
194
+ tools = {n: t for n, t in tools.items() if n in selected.selected_tools}
195
+ mcp_tools = {n: t for n, t in mcp_tools.items() if n in selected.selected_tools}
196
+ summaries = [c.model_dump() for c in catalog.skills if c.name in selected.selected_skills]
197
+ skill_spec = {
198
+ "type": "function",
199
+ "function": {
200
+ "name": "load_skill",
201
+ "description": "Load an allowed skill body or a selected reference; scripts are read, never executed.",
202
+ "parameters": {
203
+ "type": "object",
204
+ "properties": {"name": {"type": "string"}, "resource": {"type": "string"}},
205
+ "required": ["name"],
206
+ "additionalProperties": False,
207
+ },
208
+ },
209
+ }
210
+ request = ChatRequest(
211
+ messages=[
212
+ Message(role="system", content=role.prompt + "\nGoal: " + role.goal),
213
+ Message(
214
+ role="user",
215
+ content=json.dumps(
216
+ redact(
217
+ {
218
+ "task": task.model_dump(),
219
+ "inputs": inputs,
220
+ "memory": [s.model_dump() for s in snippets],
221
+ "skills": summaries,
222
+ "required_skill_bodies": {
223
+ n: routed.bodies[n] for n in role.required_skills
224
+ },
225
+ }
226
+ )
227
+ ),
228
+ ),
229
+ ],
230
+ tools=[tool.spec(name) for name, tool in tools.items()]
231
+ + ([skill_spec] if summaries else [])
232
+ + [
233
+ {
234
+ "type": "function",
235
+ "function": {
236
+ "name": n,
237
+ "description": next(c.description for c in catalog.tools if c.name == n),
238
+ "parameters": t.get("inputSchema", {}),
239
+ },
240
+ }
241
+ for n, (_, t) in mcp_tools.items()
242
+ ],
243
+ json_schema=schema.model_json_schema(),
244
+ )
245
+ retried = False
246
+ with self.store.connect() as db:
247
+ row = db.execute(
248
+ "SELECT body FROM agent_checkpoints WHERE run_id=? AND task_id=?",
249
+ (self.run_id, task.id),
250
+ ).fetchone()
251
+ checkpoint = json.loads(row[0]) if row else None
252
+ if checkpoint and checkpoint["role"] != role.model_dump():
253
+ raise PermissionError("Role changed while agent was paused")
254
+ if checkpoint:
255
+ request = ChatRequest.model_validate(checkpoint["request"])
256
+ start_step = checkpoint["step"] + (not checkpoint["calls"]) if checkpoint else 0
257
+ if checkpoint and not checkpoint["calls"]:
258
+ checkpoint = None
259
+
260
+ def save_checkpoint(step, calls):
261
+ body = {
262
+ "role": role.model_dump(),
263
+ "request": request.model_dump(mode="json"),
264
+ "step": step,
265
+ "calls": calls,
266
+ }
267
+ with self.store.connect() as db:
268
+ db.execute(
269
+ "INSERT OR REPLACE INTO agent_checkpoints VALUES(?,?,?)",
270
+ (self.run_id, task.id, json.dumps(redact(body))),
271
+ )
272
+
273
+ for step in range(start_step, role.max_steps):
274
+ from spriteforge.providers.base import ChatResult
275
+
276
+ replay = checkpoint is not None
277
+ result = (
278
+ ChatResult(tool_calls=checkpoint["calls"])
279
+ if replay
280
+ else await invoke(provider.chat, request)
281
+ )
282
+ checkpoint = None
283
+ self.store.append_event(
284
+ self.run_id, role.name, "agent.step", {"step": step + 1, "model": role.model}
285
+ )
286
+ if result.tool_calls:
287
+ if not replay:
288
+ request.messages.append(Message(role="assistant", tool_calls=result.tool_calls))
289
+ for index, raw in enumerate(result.tool_calls):
290
+ save_checkpoint(step, result.tool_calls[index:])
291
+ # Accept the provider-neutral and OpenAI-compatible tool-call shapes.
292
+ if "function" in raw:
293
+ raw = {
294
+ "id": raw["id"],
295
+ "name": raw["function"]["name"],
296
+ "arguments": json.loads(raw["function"]["arguments"]),
297
+ }
298
+ call = ToolCall.model_validate(raw)
299
+ if call.name == "load_skill":
300
+ name = call.arguments["name"]
301
+ if name not in selected.selected_skills:
302
+ raise PermissionError(f"Skill not selected: {name}")
303
+ if name not in role.allowed_skills and name not in self.catalog.headers(
304
+ role
305
+ ):
306
+ raise PermissionError(f"Skill permission revoked: {name}")
307
+ resource = call.arguments.get("resource", "SKILL.md")
308
+ if resource == "SKILL.md":
309
+ body = routed.bodies[name]
310
+ else:
311
+ # References cannot evade the shared context budget.
312
+ body = self.catalog.load(role, name, resource)
313
+ # Required bodies and prior loads are accounted separately below;
314
+ # never append a duplicate body or an over-budget reference.
315
+ loaded = sum(
316
+ token_units(m.content or "")
317
+ for m in request.messages
318
+ if m.role == "tool" and m.content and '"body"' in m.content
319
+ )
320
+ pinned = sum(
321
+ token_units(json.dumps({n: routed.bodies[n]}))
322
+ for n in role.required_skills
323
+ )
324
+ payload = {"name": name, "body": body}
325
+ already_loaded = name in role.required_skills or any(
326
+ m.role == "tool"
327
+ and m.content
328
+ and json.loads(m.content).get("name") == name
329
+ and "body" in json.loads(m.content)
330
+ for m in request.messages
331
+ )
332
+ if already_loaded and resource == "SKILL.md":
333
+ payload = {"name": name, "already_loaded": True}
334
+ elif (
335
+ pinned + loaded + token_units(json.dumps(payload))
336
+ > self.router_config.token_budget
337
+ ):
338
+ payload = {
339
+ "name": name,
340
+ "error": "Skill context token budget exhausted",
341
+ }
342
+ else:
343
+ routing.used("skills", name)
344
+ elif "." in call.name and call.name not in tools:
345
+ if call.name not in mcp_tools:
346
+ raise PermissionError(f"Tool not selected or permitted: {call.name}")
347
+ payload = await self.mcp.call(
348
+ self.run_id,
349
+ role,
350
+ call.name,
351
+ call.arguments,
352
+ f"{task.id}:{step}:{call.id}",
353
+ )
354
+ if not payload.get("isError", False):
355
+ routing.used("tools", call.name)
356
+ else:
357
+ payload = None
358
+ if payload is not None:
359
+ self.store.append_event(
360
+ self.run_id,
361
+ role.name,
362
+ "agent.tool",
363
+ {"name": call.name, "arguments": call.arguments},
364
+ )
365
+ request.messages.append(
366
+ Message(
367
+ role="tool",
368
+ tool_call_id=call.id,
369
+ content=json.dumps(redact(payload)),
370
+ )
371
+ )
372
+ save_checkpoint(step, result.tool_calls[index + 1 :])
373
+ continue
374
+ if call.name not in tools:
375
+ raise PermissionError(f"Tool not allowed: {call.name}")
376
+ tool = tools[call.name]
377
+ arguments = tool.arguments.model_validate(call.arguments)
378
+ answer = tool.result.model_validate(await invoke(tool.handler, arguments))
379
+ routing.used("tools", call.name)
380
+ self.store.append_event(
381
+ self.run_id,
382
+ role.name,
383
+ "agent.tool",
384
+ {
385
+ "name": call.name,
386
+ "call_id": call.id,
387
+ "arguments": call.arguments,
388
+ "result": redact(answer.model_dump(mode="json")),
389
+ },
390
+ )
391
+ request.messages.append(
392
+ Message(
393
+ role="tool",
394
+ tool_call_id=call.id,
395
+ content=json.dumps(redact(answer.model_dump(mode="json"))),
396
+ )
397
+ )
398
+ save_checkpoint(step, result.tool_calls[index + 1 :])
399
+ with self.store.connect() as db:
400
+ db.execute(
401
+ "DELETE FROM agent_checkpoints WHERE run_id=? AND task_id=?",
402
+ (self.run_id, task.id),
403
+ )
404
+ continue
405
+ try:
406
+ output = schema.model_validate_json(result.content or "")
407
+ except ValidationError:
408
+ if retried:
409
+ raise
410
+ retried = True
411
+ request.messages.extend(
412
+ [
413
+ Message(role="assistant", content=redact(result.content or "")),
414
+ Message(
415
+ role="user", content="Invalid output schema. Return corrected JSON."
416
+ ),
417
+ ]
418
+ )
419
+ continue
420
+ artifact = Artifact(
421
+ id=uuid4().hex,
422
+ run_id=self.run_id,
423
+ name=f"agent/{task.id or uuid4().hex}.json",
424
+ mime="application/json",
425
+ data=json.dumps(redact(output.model_dump(mode="json"))).encode(),
426
+ )
427
+ self.store.save_artifact(artifact, role.name, "agent.completed")
428
+ routing.outcome("completed")
429
+ return artifact
430
+ raise RuntimeError(f"Step cap reached: {role.name}")