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.
- spriteforge/__init__.py +1 -0
- spriteforge/__main__.py +3 -0
- spriteforge/agents/__init__.py +0 -0
- spriteforge/agents/assets.py +109 -0
- spriteforge/agents/cancellation.py +12 -0
- spriteforge/agents/director.py +50 -0
- spriteforge/agents/models.py +63 -0
- spriteforge/agents/roles/art-director.md +15 -0
- spriteforge/agents/roles/art-reviewer.md +14 -0
- spriteforge/agents/roles/director.md +14 -0
- spriteforge/agents/roles/game-designer.md +15 -0
- spriteforge/agents/roles/level-tile-designer.md +16 -0
- spriteforge/agents/roles/librarian.md +13 -0
- spriteforge/agents/roles/menu-designer.md +15 -0
- spriteforge/agents/roles/pixel-artist.md +14 -0
- spriteforge/agents/roles/programmer.md +14 -0
- spriteforge/agents/roles/qa-playtester.md +13 -0
- spriteforge/agents/roles/sound-director.md +14 -0
- spriteforge/agents/roles.py +39 -0
- spriteforge/agents/router.py +233 -0
- spriteforge/agents/router_eval.py +145 -0
- spriteforge/agents/routing_state.py +133 -0
- spriteforge/agents/runtime.py +430 -0
- spriteforge/agents/workflow.py +389 -0
- spriteforge/app/__init__.py +0 -0
- spriteforge/app/agents_view.py +248 -0
- spriteforge/app/chat.py +114 -0
- spriteforge/app/extensions.py +115 -0
- spriteforge/app/index.html +279 -0
- spriteforge/app/local_only.py +49 -0
- spriteforge/app/server.py +358 -0
- spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
- spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
- spriteforge/app/static/index.html +13 -0
- spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
- spriteforge/app/studio.py +278 -0
- spriteforge/app/workers.py +73 -0
- spriteforge/cli.py +241 -0
- spriteforge/config.py +193 -0
- spriteforge/config.yaml +157 -0
- spriteforge/core/__init__.py +0 -0
- spriteforge/core/acting.py +16 -0
- spriteforge/core/cost.py +236 -0
- spriteforge/core/credentials.py +24 -0
- spriteforge/core/demo.py +79 -0
- spriteforge/core/forms.py +20 -0
- spriteforge/core/gates.py +175 -0
- spriteforge/core/security.py +42 -0
- spriteforge/core/state.py +222 -0
- spriteforge/core/team.py +15 -0
- spriteforge/core/usage.py +68 -0
- spriteforge/game/__init__.py +0 -0
- spriteforge/game/actors.py +377 -0
- spriteforge/game/agent_tools.py +178 -0
- spriteforge/game/art.py +2097 -0
- spriteforge/game/art_checks.py +190 -0
- spriteforge/game/art_review.py +518 -0
- spriteforge/game/build.py +134 -0
- spriteforge/game/bundle.py +1178 -0
- spriteforge/game/cli.py +119 -0
- spriteforge/game/codegen.py +433 -0
- spriteforge/game/codeinfo.py +154 -0
- spriteforge/game/dbsource.py +137 -0
- spriteforge/game/design.py +342 -0
- spriteforge/game/export.py +62 -0
- spriteforge/game/gamedb.py +191 -0
- spriteforge/game/image_cache.py +61 -0
- spriteforge/game/lessons.py +259 -0
- spriteforge/game/level_gen.py +118 -0
- spriteforge/game/level_preview.py +97 -0
- spriteforge/game/levels.py +934 -0
- spriteforge/game/loops.py +38 -0
- spriteforge/game/menus.py +712 -0
- spriteforge/game/mock_game.js +109 -0
- spriteforge/game/mock_platformer.js +58 -0
- spriteforge/game/motion.py +881 -0
- spriteforge/game/pipeline.py +1768 -0
- spriteforge/game/plan.py +502 -0
- spriteforge/game/playtest.py +318 -0
- spriteforge/game/qa.py +664 -0
- spriteforge/game/qa_checks.py +223 -0
- spriteforge/game/qa_runtime.py +565 -0
- spriteforge/game/reach.py +807 -0
- spriteforge/game/record.py +138 -0
- spriteforge/game/research.py +262 -0
- spriteforge/game/revision.py +225 -0
- spriteforge/game/sdk/API.md +104 -0
- spriteforge/game/sdk/FLOW.md +19 -0
- spriteforge/game/sdk/LEVELS.md +65 -0
- spriteforge/game/sdk/sf.js +418 -0
- spriteforge/game/sdk/sfflow.js +442 -0
- spriteforge/game/sdk/sflevel.js +683 -0
- spriteforge/game/sdk/sfsound-editor.js +225 -0
- spriteforge/game/sdk/sfsound.js +260 -0
- spriteforge/game/sdk/sounds.html +67 -0
- spriteforge/game/sounds.py +793 -0
- spriteforge/game/story.py +188 -0
- spriteforge/game/structure.py +98 -0
- spriteforge/game/team_reports.py +394 -0
- spriteforge/game/tilesets.py +70 -0
- spriteforge/game/title_art.py +186 -0
- spriteforge/genres/__init__.py +0 -0
- spriteforge/library/__init__.py +15 -0
- spriteforge/library/cli.py +126 -0
- spriteforge/library/describe.py +242 -0
- spriteforge/library/looks.py +154 -0
- spriteforge/library/models.py +79 -0
- spriteforge/library/store.py +422 -0
- spriteforge/library/tilesets/README.md +32 -0
- spriteforge/library/tilesets/gandalf-seasons.json +111 -0
- spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
- spriteforge/mcp/__init__.py +0 -0
- spriteforge/mcp/cli.py +58 -0
- spriteforge/mcp/client.py +57 -0
- spriteforge/mcp/filesystem_read.py +32 -0
- spriteforge/mcp/gamedb.py +56 -0
- spriteforge/mcp/library.py +179 -0
- spriteforge/mcp/memory.py +37 -0
- spriteforge/mcp/pixel_tools.py +145 -0
- spriteforge/mcp/registry.py +145 -0
- spriteforge/mcp/service.py +224 -0
- spriteforge/mcp/templates/context7.json +6 -0
- spriteforge/mcp/templates/fetch.json +6 -0
- spriteforge/mcp/templates/filesystem-read.json +6 -0
- spriteforge/mcp/templates/pixel-tools.json +6 -0
- spriteforge/memory/README.md +10 -0
- spriteforge/memory/__init__.py +0 -0
- spriteforge/memory/cli.py +131 -0
- spriteforge/memory/okf/__init__.py +1 -0
- spriteforge/memory/okf/catalog.py +331 -0
- spriteforge/memory/okf/codec.py +63 -0
- spriteforge/memory/okf/git.py +104 -0
- spriteforge/memory/okf/indexes.py +50 -0
- spriteforge/memory/okf/models.py +114 -0
- spriteforge/memory/okf/search.py +53 -0
- spriteforge/memory/okf/seed.py +81 -0
- spriteforge/memory/okf/seeds/genres.yaml +121 -0
- spriteforge/memory/okf/seeds/other.yaml +150 -0
- spriteforge/memory/okf/store.py +402 -0
- spriteforge/memory/okf/validate.py +115 -0
- spriteforge/pixel/__init__.py +17 -0
- spriteforge/pixel/atlas.py +110 -0
- spriteforge/pixel/grid.py +174 -0
- spriteforge/pixel/palette.py +156 -0
- spriteforge/pixel/palettes/dungeon.json +9 -0
- spriteforge/pixel/postprocess.py +170 -0
- spriteforge/pixel/preview.py +40 -0
- spriteforge/pixel/schemas.py +139 -0
- spriteforge/pixel/source.py +237 -0
- spriteforge/pixel/terrain.py +48 -0
- spriteforge/pixel/validate.py +138 -0
- spriteforge/prompts/anim_frame.txt +1 -0
- spriteforge/prompts/character.txt +1 -0
- spriteforge/prompts/item.txt +1 -0
- spriteforge/prompts/tile.txt +1 -0
- spriteforge/prompts/ui.txt +1 -0
- spriteforge/providers/__init__.py +0 -0
- spriteforge/providers/base.py +97 -0
- spriteforge/providers/http.py +38 -0
- spriteforge/providers/mock.py +111 -0
- spriteforge/providers/muse_image.py +60 -0
- spriteforge/providers/muse_search.py +91 -0
- spriteforge/providers/muse_spark.py +66 -0
- spriteforge/skills/__init__.py +0 -0
- spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
- spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
- spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
- spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
- spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
- spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
- spriteforge/skills/builtin/level-design/SKILL.md +169 -0
- spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
- spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
- spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
- spriteforge/skills/builtin/platformer/SKILL.md +153 -0
- spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
- spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
- spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
- spriteforge/skills/catalog.py +200 -0
- spriteforge/skills/cli.py +41 -0
- spriteforge/stages/__init__.py +0 -0
- spriteforge/stages/asset_context.py +301 -0
- spriteforge/stages/asset_models.py +192 -0
- spriteforge/stages/asset_render.py +366 -0
- spriteforge/stages/asset_review.py +140 -0
- spriteforge/stages/asset_store.py +94 -0
- spriteforge/stages/chat_changes.py +236 -0
- spriteforge/stages/concept.py +42 -0
- spriteforge/stages/game.py +1699 -0
- spriteforge/stages/game_confirm.py +112 -0
- spriteforge/stages/game_final.py +220 -0
- spriteforge/stages/game_record.py +246 -0
- spriteforge/stages/gameplay.py +218 -0
- spriteforge/stages/intake.py +372 -0
- spriteforge/stages/preview_assets.py +212 -0
- spriteforge/stages/preview_models.py +167 -0
- spriteforge/stages/preview_render.py +169 -0
- spriteforge/stages/preview_repair.py +61 -0
- spriteforge/stages/preview_review.py +169 -0
- spriteforge/stages/production.py +170 -0
- spriteforge/stages/references.py +105 -0
- spriteforge/stages/rewind.py +128 -0
- spriteforge/stages/workflow.py +440 -0
- spriteforge/templates/phaser/audit.js +45 -0
- spriteforge/templates/phaser/font.png +0 -0
- spriteforge/templates/phaser/font.xml +1 -0
- spriteforge/templates/phaser/preview.html +9 -0
- spriteforge/templates/phaser/room.js +49 -0
- spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
- spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
- spriteforge-0.1.1.dist-info/METADATA +364 -0
- spriteforge-0.1.1.dist-info/RECORD +218 -0
- spriteforge-0.1.1.dist-info/WHEEL +5 -0
- spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
- spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
- 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}")
|