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,389 @@
|
|
|
1
|
+
"""Offline agent-runtime acceptance workflow, resumed entirely from local artifacts and gates."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from spriteforge.config import BudgetConfig, RouterConfig, load_config
|
|
9
|
+
from spriteforge.core.cost import Budget
|
|
10
|
+
from spriteforge.core.gates import Gates, new_gate
|
|
11
|
+
from spriteforge.core.state import Store
|
|
12
|
+
from spriteforge.memory.okf.git import write_lock
|
|
13
|
+
from spriteforge.memory.okf.seed import seed
|
|
14
|
+
from spriteforge.memory.okf.store import KnowledgeStore
|
|
15
|
+
from spriteforge.providers.base import ChatResult
|
|
16
|
+
from spriteforge.providers.mock import MockImageProvider, MockLLMProvider
|
|
17
|
+
|
|
18
|
+
from .assets import AssetResult, Assets, GenerateAsset
|
|
19
|
+
from .director import Director
|
|
20
|
+
from .models import Changes, RoleOutput, StageNode, StagePlan
|
|
21
|
+
from .roles import Role, load_roles
|
|
22
|
+
from .runtime import AgentRuntime, AgentTask, Tool
|
|
23
|
+
|
|
24
|
+
WORKFLOW = "agent-runtime-mock-v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Flow(BaseModel):
|
|
28
|
+
roles: dict[str, Role]
|
|
29
|
+
plan: StagePlan | None = None
|
|
30
|
+
outputs: dict[str, str] = Field(default_factory=dict)
|
|
31
|
+
completed: list[str] = Field(default_factory=list)
|
|
32
|
+
handled: list[str] = Field(default_factory=list)
|
|
33
|
+
changes: Changes | None = None
|
|
34
|
+
change_artifact: str | None = None
|
|
35
|
+
rework: StagePlan | None = None
|
|
36
|
+
rework_done: list[str] = Field(default_factory=list)
|
|
37
|
+
revisions: dict[str, int] = Field(default_factory=dict)
|
|
38
|
+
source_gate: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def start_run(
|
|
42
|
+
store: Store,
|
|
43
|
+
goal: str,
|
|
44
|
+
budget: BudgetConfig,
|
|
45
|
+
roles_directory: Path | None = None,
|
|
46
|
+
router: RouterConfig | None = None,
|
|
47
|
+
):
|
|
48
|
+
# Snapshot user edits for this run; subsequent runs reload the Markdown files.
|
|
49
|
+
roles = load_roles(roles_directory)
|
|
50
|
+
required = {
|
|
51
|
+
"Director",
|
|
52
|
+
"Game Designer",
|
|
53
|
+
"Art Director",
|
|
54
|
+
"Pixel Artist",
|
|
55
|
+
"Level/Tile Designer",
|
|
56
|
+
"Programmer",
|
|
57
|
+
"QA Playtester",
|
|
58
|
+
"Librarian",
|
|
59
|
+
}
|
|
60
|
+
if required - roles.keys():
|
|
61
|
+
raise ValueError("Missing required roles")
|
|
62
|
+
run = store.create_run(
|
|
63
|
+
{
|
|
64
|
+
"workflow": WORKFLOW,
|
|
65
|
+
"goal": goal,
|
|
66
|
+
"budget": budget.model_dump(),
|
|
67
|
+
"router": (router or load_config().router).model_dump(),
|
|
68
|
+
"roles": {name: role.model_dump() for name, role in roles.items()},
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
Gates(store)
|
|
72
|
+
return run
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mock_plan() -> StagePlan:
|
|
76
|
+
definitions = [
|
|
77
|
+
("brief", None, "G1 Brief"),
|
|
78
|
+
("design", "Game Designer", None),
|
|
79
|
+
("art", "Art Director", None),
|
|
80
|
+
("concept", None, "G2 Concept preview"),
|
|
81
|
+
("assets", "Pixel Artist", None),
|
|
82
|
+
("contact", None, "G3 Asset contact sheet"),
|
|
83
|
+
("level", "Level/Tile Designer", None),
|
|
84
|
+
("code", "Programmer", None),
|
|
85
|
+
("qa", "QA Playtester", None),
|
|
86
|
+
("playtest", None, "G4 Playtest"),
|
|
87
|
+
("memory", "Librarian", None),
|
|
88
|
+
("final", None, "G5 Final review"),
|
|
89
|
+
]
|
|
90
|
+
return StagePlan(
|
|
91
|
+
stages=[
|
|
92
|
+
StageNode(
|
|
93
|
+
id=ident,
|
|
94
|
+
role=role,
|
|
95
|
+
gate=gate,
|
|
96
|
+
goal=gate or f"Produce mock {role} artifact",
|
|
97
|
+
depends_on=[definitions[i - 1][0]] if i else [],
|
|
98
|
+
)
|
|
99
|
+
for i, (ident, role, gate) in enumerate(definitions)
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def mock_changes(text: str, records: dict[str, AssetResult]) -> Changes:
|
|
105
|
+
"""Scripted offline Director behavior; unrecognized feedback stays conservative."""
|
|
106
|
+
lowered = text.casefold()
|
|
107
|
+
named = [ident for ident in records if ident.casefold() in lowered]
|
|
108
|
+
if named:
|
|
109
|
+
target, regen = named, True
|
|
110
|
+
elif "style" in lowered or "palette" in lowered:
|
|
111
|
+
target, regen = "style", False
|
|
112
|
+
elif "level" in lowered or "room" in lowered:
|
|
113
|
+
target, regen = "level", False
|
|
114
|
+
else:
|
|
115
|
+
target, regen = "mechanic", False
|
|
116
|
+
return Changes(
|
|
117
|
+
requests=[
|
|
118
|
+
{
|
|
119
|
+
"target": target,
|
|
120
|
+
"instruction": text,
|
|
121
|
+
"severity": "minor",
|
|
122
|
+
"scope": "Only the listed targets",
|
|
123
|
+
"needs_regen": regen,
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def mock_replan(changes: Changes) -> StagePlan:
|
|
130
|
+
stages = []
|
|
131
|
+
for i, change in enumerate(changes.requests):
|
|
132
|
+
role = (
|
|
133
|
+
"Pixel Artist"
|
|
134
|
+
if change.needs_regen
|
|
135
|
+
else {
|
|
136
|
+
"style": "Art Director",
|
|
137
|
+
"level": "Level/Tile Designer",
|
|
138
|
+
"mechanic": "Game Designer",
|
|
139
|
+
}.get(str(change.target), "Art Director")
|
|
140
|
+
)
|
|
141
|
+
stages.append(
|
|
142
|
+
StageNode(
|
|
143
|
+
id=f"revision-{i}",
|
|
144
|
+
role=role,
|
|
145
|
+
goal=change.instruction,
|
|
146
|
+
depends_on=[f"revision-{i - 1}"] if i else [],
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return StagePlan(stages=stages)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def workflow_step(store: Store, run_id: str) -> bool:
|
|
153
|
+
from spriteforge.mcp.service import ConfirmationRequired
|
|
154
|
+
|
|
155
|
+
with write_lock(store.path.parent / f".{run_id}.runtime.lock"):
|
|
156
|
+
try:
|
|
157
|
+
return asyncio.run(_step(store, run_id))
|
|
158
|
+
except ConfirmationRequired:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _step(store: Store, run_id: str) -> bool:
|
|
163
|
+
run = store.get_run(run_id)
|
|
164
|
+
if run.config.get("workflow") != WORKFLOW:
|
|
165
|
+
raise ValueError("Unknown runtime workflow")
|
|
166
|
+
if run.status in {"completed", "aborted"}:
|
|
167
|
+
return False
|
|
168
|
+
gates = Gates(store)
|
|
169
|
+
if gates.pending(run_id):
|
|
170
|
+
return False
|
|
171
|
+
with store.connect() as db:
|
|
172
|
+
db.execute("CREATE TABLE IF NOT EXISTS agent_flows (run_id TEXT PRIMARY KEY, body TEXT)")
|
|
173
|
+
row = db.execute("SELECT body FROM agent_flows WHERE run_id=?", (run_id,)).fetchone()
|
|
174
|
+
flow = (
|
|
175
|
+
Flow.model_validate_json(row[0])
|
|
176
|
+
if row
|
|
177
|
+
else Flow(roles={n: Role.model_validate(r) for n, r in run.config["roles"].items()})
|
|
178
|
+
)
|
|
179
|
+
budget = Budget(store, run_id, BudgetConfig.model_validate(run.config["budget"]))
|
|
180
|
+
assets = Assets(store, run_id, MockImageProvider(budget))
|
|
181
|
+
memory = KnowledgeStore(store.path.parent / "memory", store, run_id)
|
|
182
|
+
seed(memory)
|
|
183
|
+
|
|
184
|
+
def runtime(role: Role, responses: list, tools=None) -> AgentRuntime:
|
|
185
|
+
if role.model != "mock":
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"The offline workflow requires model: mock; inject a provider in AgentRuntime"
|
|
188
|
+
)
|
|
189
|
+
return AgentRuntime(store, run_id, memory, MockLLMProvider(budget, responses), tools)
|
|
190
|
+
|
|
191
|
+
def director(responses):
|
|
192
|
+
return Director(
|
|
193
|
+
runtime(flow.roles["Director"], responses), flow.roles["Director"], flow.roles
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def save(kind="workflow.progress", final=False):
|
|
197
|
+
with store.connect() as db:
|
|
198
|
+
db.execute(
|
|
199
|
+
"INSERT OR REPLACE INTO agent_flows VALUES(?,?)", (run_id, flow.model_dump_json())
|
|
200
|
+
)
|
|
201
|
+
db.execute(
|
|
202
|
+
"UPDATE runs SET cursor=cursor+1,status=? WHERE id=?",
|
|
203
|
+
("completed" if final else "running", run_id),
|
|
204
|
+
)
|
|
205
|
+
store._event(db, run_id, "Director", kind, {"completed": flow.completed})
|
|
206
|
+
|
|
207
|
+
if flow.plan is None:
|
|
208
|
+
flow.plan, ident = await director([mock_plan().model_dump()]).plan(
|
|
209
|
+
AgentTask(id="initial-plan", goal=run.config["goal"])
|
|
210
|
+
)
|
|
211
|
+
flow.outputs["plan"] = ident
|
|
212
|
+
save("director.planned")
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
async def execute(stage: StageNode, task_id: str, inputs: list[str], rework=False):
|
|
216
|
+
role = flow.roles[stage.role]
|
|
217
|
+
tools, responses = {}, []
|
|
218
|
+
expected_assets = set(assets.records())
|
|
219
|
+
if stage.role == "Pixel Artist":
|
|
220
|
+
allowed = set(flow.revisions) if rework else {"hero", "tile"}
|
|
221
|
+
expected_assets.update(allowed)
|
|
222
|
+
for index, ident in enumerate(sorted(allowed)):
|
|
223
|
+
responses.append(
|
|
224
|
+
ChatResult(
|
|
225
|
+
tool_calls=[
|
|
226
|
+
{
|
|
227
|
+
"id": f"image-{index}",
|
|
228
|
+
"name": "generate_asset",
|
|
229
|
+
"arguments": {
|
|
230
|
+
"asset_id": ident,
|
|
231
|
+
"instruction": stage.goal + " " + ident,
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
tools["generate_asset"] = Tool(
|
|
238
|
+
GenerateAsset,
|
|
239
|
+
AssetResult,
|
|
240
|
+
lambda request: assets.generate(
|
|
241
|
+
request, allowed=allowed, revisions=flow.revisions if rework else None
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
responses.append(
|
|
245
|
+
RoleOutput(
|
|
246
|
+
summary=stage.goal + " (offline mock)", asset_ids=sorted(expected_assets)
|
|
247
|
+
).model_dump()
|
|
248
|
+
)
|
|
249
|
+
result = await Director(
|
|
250
|
+
runtime(role, responses, tools), flow.roles["Director"], flow.roles
|
|
251
|
+
).dispatch(stage, AgentTask(id=task_id, goal=stage.goal, inputs=inputs), RoleOutput)
|
|
252
|
+
return result.id
|
|
253
|
+
|
|
254
|
+
# Handle answered gates before dispatching another stage. All interpretation and
|
|
255
|
+
# re-planning results are persisted artifacts, and replay has deterministic task ids.
|
|
256
|
+
answered = next(
|
|
257
|
+
(g for g in gates.all(run_id) if g.status != "pending" and g.id not in flow.handled), None
|
|
258
|
+
)
|
|
259
|
+
if answered:
|
|
260
|
+
if answered.status == "aborted":
|
|
261
|
+
return False
|
|
262
|
+
feedback_artifact = next(
|
|
263
|
+
a.id for a in store.artifacts(run_id) if a.name == f"feedback/{answered.id}.json"
|
|
264
|
+
)
|
|
265
|
+
if answered.status == "changes_requested":
|
|
266
|
+
changes = mock_changes(answered.response.text, assets.records())
|
|
267
|
+
flow.changes, flow.change_artifact = await director([changes.model_dump()]).feedback(
|
|
268
|
+
AgentTask(
|
|
269
|
+
id=f"interpret-{answered.id}",
|
|
270
|
+
goal=answered.response.text,
|
|
271
|
+
inputs=[feedback_artifact],
|
|
272
|
+
),
|
|
273
|
+
set(assets.records()),
|
|
274
|
+
)
|
|
275
|
+
flow.source_gate = answered.id
|
|
276
|
+
flow.handled.append(answered.id)
|
|
277
|
+
save("director.changes_proposed")
|
|
278
|
+
gates.open(
|
|
279
|
+
run_id,
|
|
280
|
+
new_gate(
|
|
281
|
+
answered.stage,
|
|
282
|
+
"Confirm parsed change requests",
|
|
283
|
+
[flow.change_artifact],
|
|
284
|
+
kind="confirm_changes",
|
|
285
|
+
parent_id=answered.id,
|
|
286
|
+
changes=flow.changes,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
return True
|
|
290
|
+
if answered.kind == "confirm_changes":
|
|
291
|
+
flow.rework, plan_id = await director([mock_replan(flow.changes).model_dump()]).replan(
|
|
292
|
+
AgentTask(
|
|
293
|
+
id=f"replan-{answered.id}",
|
|
294
|
+
goal="Re-plan only the confirmed changes",
|
|
295
|
+
inputs=[flow.change_artifact, feedback_artifact],
|
|
296
|
+
),
|
|
297
|
+
flow.changes,
|
|
298
|
+
)
|
|
299
|
+
flow.outputs[f"replan-{answered.id}"] = plan_id
|
|
300
|
+
flow.rework_done = []
|
|
301
|
+
flow.revisions = {
|
|
302
|
+
ident: assets.records()[ident].version + 1
|
|
303
|
+
for change in flow.changes.requests
|
|
304
|
+
if change.needs_regen
|
|
305
|
+
for ident in change.target
|
|
306
|
+
}
|
|
307
|
+
flow.handled.append(answered.id)
|
|
308
|
+
save("director.replanned")
|
|
309
|
+
return True
|
|
310
|
+
flow.completed.append(answered.stage)
|
|
311
|
+
flow.outputs[f"gate-{answered.stage}"] = feedback_artifact
|
|
312
|
+
flow.handled.append(answered.id)
|
|
313
|
+
save()
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
if flow.rework:
|
|
317
|
+
stage = Director.ready(flow.rework, set(flow.rework_done))
|
|
318
|
+
if stage:
|
|
319
|
+
if stage.gate:
|
|
320
|
+
raise ValueError("Revision plan must route roles only")
|
|
321
|
+
ident = await execute(
|
|
322
|
+
stage, f"rework-{flow.source_gate}-{stage.id}", [flow.change_artifact], rework=True
|
|
323
|
+
)
|
|
324
|
+
flow.outputs[f"rework-{flow.source_gate}-{stage.id}"] = ident
|
|
325
|
+
flow.rework_done.append(stage.id)
|
|
326
|
+
save()
|
|
327
|
+
return True
|
|
328
|
+
original = next(g for g in gates.all(run_id) if g.id == flow.source_gate)
|
|
329
|
+
flow.rework = None
|
|
330
|
+
flow.changes = None
|
|
331
|
+
save("changes.applied")
|
|
332
|
+
images = [r.artifact_id for r in assets.records().values()]
|
|
333
|
+
sheet = assets.contact_sheet()
|
|
334
|
+
gates.open(
|
|
335
|
+
run_id, new_gate(original.stage, original.title, images + ([sheet] if sheet else []))
|
|
336
|
+
)
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
stage = Director.ready(flow.plan, set(flow.completed))
|
|
340
|
+
if stage is None:
|
|
341
|
+
save("run.completed", final=True)
|
|
342
|
+
return False
|
|
343
|
+
if stage.gate:
|
|
344
|
+
previous = [g for g in gates.all(run_id) if g.stage == stage.id]
|
|
345
|
+
# Crash recovery: a proposal saved immediately before opening its confirmation.
|
|
346
|
+
if flow.source_gate and flow.changes and not flow.rework and previous:
|
|
347
|
+
gates.open(
|
|
348
|
+
run_id,
|
|
349
|
+
new_gate(
|
|
350
|
+
stage.id,
|
|
351
|
+
"Confirm parsed change requests",
|
|
352
|
+
[flow.change_artifact],
|
|
353
|
+
kind="confirm_changes",
|
|
354
|
+
parent_id=flow.source_gate,
|
|
355
|
+
changes=flow.changes,
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
images = [r.artifact_id for r in assets.records().values()]
|
|
360
|
+
sheet = assets.contact_sheet()
|
|
361
|
+
kwargs = {}
|
|
362
|
+
if stage.id == "brief":
|
|
363
|
+
kwargs["form_schema"] = {
|
|
364
|
+
"type": "object",
|
|
365
|
+
"properties": {"brief": {"type": "string", "title": "Game brief"}},
|
|
366
|
+
}
|
|
367
|
+
gates.open(
|
|
368
|
+
run_id,
|
|
369
|
+
new_gate(
|
|
370
|
+
stage.id,
|
|
371
|
+
stage.gate,
|
|
372
|
+
images + ([sheet] if sheet else []) + list(flow.outputs.values())[-2:],
|
|
373
|
+
**kwargs,
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
return True
|
|
377
|
+
# Include approved feedback and upstream role outputs explicitly, including
|
|
378
|
+
# artifacts across intervening gates and confirmed revision artifacts.
|
|
379
|
+
inputs = list(dict.fromkeys(flow.outputs.values()))
|
|
380
|
+
flow.outputs[stage.id] = await execute(stage, f"stage-{stage.id}", inputs)
|
|
381
|
+
flow.completed.append(stage.id)
|
|
382
|
+
save()
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def advance_until_gate(store: Store, run_id: str) -> None:
|
|
387
|
+
while workflow_step(store, run_id):
|
|
388
|
+
if store.get_run(run_id).status == "paused":
|
|
389
|
+
break
|
|
File without changes
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The studio's view of a run as a team of agents, derived from the append-only journal (events
|
|
2
|
+
and gates): who works in which order, what each produced and cost, and each agent's thread."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from spriteforge.core.cost import ALLOWANCE_EVENTS
|
|
9
|
+
from spriteforge.core.gates import Gate, Gates
|
|
10
|
+
from spriteforge.core.state import Event, Store
|
|
11
|
+
from spriteforge.core.team import AGENTS
|
|
12
|
+
from spriteforge.stages.chat_changes import changes
|
|
13
|
+
|
|
14
|
+
# The agents whose work a gate reviews (the Director's chat shows every gate as well).
|
|
15
|
+
GATE_OWNERS = {
|
|
16
|
+
"design": ["Game Designer"],
|
|
17
|
+
"concept": ["Art Director"],
|
|
18
|
+
"assets": ["Art Director", "Pixel Artist"],
|
|
19
|
+
"gameplay": ["Art Director", "Pixel Artist"],
|
|
20
|
+
"menus": ["Menu Designer"],
|
|
21
|
+
"playtest": ["Programmer", "QA Playtester"],
|
|
22
|
+
"final": ["Librarian"],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# The Director narrates steps that other agents do ("build v1: the Programmer is writing the
|
|
26
|
+
# game"); the first matching pattern names the agent at work.
|
|
27
|
+
NARRATION = [
|
|
28
|
+
(r"Art Reviewer|vision review", "Art Reviewer"),
|
|
29
|
+
(r"Programmer|attempt \d+", "Programmer"),
|
|
30
|
+
(r"menu", "Menu Designer"),
|
|
31
|
+
(r"art \(|blocking issue", "Pixel Artist"),
|
|
32
|
+
(r"playing the game|recording|QA|tests?\b", "QA Playtester"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Journal noise that is not an agent's own work (each memory read, the cost ledger).
|
|
36
|
+
QUIET = {"memory.retrieved", "cost.reserved", "cost.succeeded", "cost.failed",
|
|
37
|
+
# Chat and its bookkeeping: talking about the work is not doing it.
|
|
38
|
+
"chat.user", "chat.agent", "chat.applied", "chat.unapplied", "rewind.declined",
|
|
39
|
+
"run.stop_requested", "run.stop_cleared", "round.superseded", "round.rewound",
|
|
40
|
+
"round.replanned",
|
|
41
|
+
# A plan or report is about the work (an agent's plan comes before it starts).
|
|
42
|
+
"agent.report"}
|
|
43
|
+
RUN_EVENTS = {"run.created", "run.failed", "run.completed", "run.retry_requested", "game.built",
|
|
44
|
+
"run.paused"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def owners(gate: Gate) -> list[str]:
|
|
48
|
+
"""The Director, the agents whose work the gate reviews and the agents asking for it."""
|
|
49
|
+
return list(dict.fromkeys(["Director", *GATE_OWNERS.get(gate.stage, []), *gate.agents]))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def actor(event: Event) -> str | None:
|
|
53
|
+
"""The agent whose work this event is, or None (the human, the ledger, memory reads)."""
|
|
54
|
+
if event.type == "cost.reserved":
|
|
55
|
+
return event.payload.get("agent")
|
|
56
|
+
if event.type in QUIET or event.agent not in AGENTS:
|
|
57
|
+
return None
|
|
58
|
+
if event.agent == "Director" and event.type == "game.progress":
|
|
59
|
+
message = str(event.payload.get("message", ""))
|
|
60
|
+
return next((a for p, a in NARRATION if re.search(p, message)), "Director")
|
|
61
|
+
return event.agent
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AgentSummary(BaseModel):
|
|
65
|
+
name: str
|
|
66
|
+
status: str # "waiting" | "working" | "needs_you" | "done"
|
|
67
|
+
needs_you: int # open gates this agent's chat holds
|
|
68
|
+
artifacts: int
|
|
69
|
+
calls: int
|
|
70
|
+
images: int
|
|
71
|
+
cost: float
|
|
72
|
+
allowance: float # what it spent since the human's last action (its cap: budget.max_agent_cost)
|
|
73
|
+
last: str | None # its latest progress message
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def summarize(store: Store, run_id: str, busy: bool) -> list[AgentSummary]:
|
|
77
|
+
"""`busy`: a worker is stepping this run now (so its latest actor is working)."""
|
|
78
|
+
stats = {
|
|
79
|
+
a: {"artifacts": 0, "calls": 0, "images": 0, "cost": 0.0, "allowance": 0.0, "last": None}
|
|
80
|
+
for a in AGENTS
|
|
81
|
+
}
|
|
82
|
+
seen, attempts, current = set(), {}, None
|
|
83
|
+
for event in store.events(run_id):
|
|
84
|
+
who = actor(event)
|
|
85
|
+
if event.type in ALLOWANCE_EVENTS: # a human action: every agent's allowance restarts
|
|
86
|
+
for s in stats.values():
|
|
87
|
+
s["allowance"] = 0.0
|
|
88
|
+
if event.type == "cost.reserved":
|
|
89
|
+
# Calls booked before agents were recorded go to whoever was working then.
|
|
90
|
+
attempts[event.payload["attempt_id"]] = who or current or "Director"
|
|
91
|
+
elif event.type in {"cost.succeeded", "cost.failed"}:
|
|
92
|
+
usage = event.payload["usage"]
|
|
93
|
+
agent = attempts.get(event.payload["attempt_id"])
|
|
94
|
+
if agent in stats and event.type == "cost.succeeded":
|
|
95
|
+
stats[agent]["calls"] += 1
|
|
96
|
+
stats[agent]["images"] += bool(usage.get("image"))
|
|
97
|
+
stats[agent]["cost"] += usage.get("estimated_cost") or 0.0
|
|
98
|
+
stats[agent]["allowance"] += usage.get("estimated_cost") or 0.0
|
|
99
|
+
if who:
|
|
100
|
+
seen.add(who)
|
|
101
|
+
current = who
|
|
102
|
+
if event.type == "artifact.created":
|
|
103
|
+
stats[who]["artifacts"] += 1
|
|
104
|
+
elif event.type == "game.progress":
|
|
105
|
+
stats[who]["last"] = event.payload.get("message")
|
|
106
|
+
gate = Gates(store).pending(run_id)
|
|
107
|
+
waiting_on = set(owners(gate)) if gate else set()
|
|
108
|
+
result = []
|
|
109
|
+
for name in AGENTS:
|
|
110
|
+
if name in waiting_on:
|
|
111
|
+
status = "needs_you"
|
|
112
|
+
elif busy and not gate and name == current:
|
|
113
|
+
status = "working"
|
|
114
|
+
elif name in seen:
|
|
115
|
+
status = "done"
|
|
116
|
+
else:
|
|
117
|
+
status = "waiting"
|
|
118
|
+
result.append(
|
|
119
|
+
AgentSummary(
|
|
120
|
+
name=name,
|
|
121
|
+
status=status,
|
|
122
|
+
needs_you=int(name in waiting_on),
|
|
123
|
+
**{**stats[name], "cost": round(stats[name]["cost"], 6),
|
|
124
|
+
"allowance": round(stats[name]["allowance"], 6)},
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def item(event: Event, kind: str, **fields) -> dict:
|
|
131
|
+
return {"id": event.id, "at": event.timestamp, "agent": event.agent, "kind": kind, **fields}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def thread(store: Store, run_id: str, agent: str, after: int = 0) -> list[dict]:
|
|
135
|
+
"""The agent's chat as one timeline: its messages, progress, artifacts and the gates that
|
|
136
|
+
review its work. The Director's thread is the run's timeline: every agent's progress, every
|
|
137
|
+
gate and the run's milestones."""
|
|
138
|
+
director = agent == "Director"
|
|
139
|
+
gates = {g.id: g for g in Gates(store).all(run_id)}
|
|
140
|
+
metas = artifact_metas(store, run_id)
|
|
141
|
+
by_name = {m["name"]: m for m in metas.values()}
|
|
142
|
+
|
|
143
|
+
def readable(meta): # a JSON record is shown as the Markdown its agent wrote with it
|
|
144
|
+
twin = by_name.get(meta["name"].removesuffix(".json") + ".md")
|
|
145
|
+
return twin if meta["mime"] == "application/json" and twin else meta
|
|
146
|
+
|
|
147
|
+
applied = {c["id"]: c["applied"] for c in changes(store, run_id)}
|
|
148
|
+
offers = go_back_states(store, run_id)
|
|
149
|
+
items = []
|
|
150
|
+
for event in store.events(run_id, after):
|
|
151
|
+
kind, payload = event.type, event.payload
|
|
152
|
+
if kind == "chat.user" and payload.get("to") == agent:
|
|
153
|
+
fields = chat_fields(payload)
|
|
154
|
+
if event.id in applied: # a filed change: queued until a step takes it
|
|
155
|
+
fields["status"] = "applied" if applied[event.id] else "queued"
|
|
156
|
+
items.append(item(event, "chat", role="user", **fields))
|
|
157
|
+
elif kind == "chat.agent" and event.agent == agent:
|
|
158
|
+
fields = chat_fields(payload)
|
|
159
|
+
if "rewind" in payload:
|
|
160
|
+
fields["rewind"] = {**payload["rewind"], "state": offers.get(event.id, "open")}
|
|
161
|
+
items.append(item(event, "chat", role="agent", **fields))
|
|
162
|
+
elif kind == "agent.report" and event.agent == agent:
|
|
163
|
+
items.append(item(event, "report", report=payload))
|
|
164
|
+
elif kind == "reference.added" and payload.get("agent") in (agent, "Director"):
|
|
165
|
+
# The Director's references are every agent's: shown in every chat.
|
|
166
|
+
items.append(item(event, "reference", reference=payload))
|
|
167
|
+
elif kind == "round.rewound" and director:
|
|
168
|
+
items.append(item(event, "run", type=kind, message=payload.get("message")))
|
|
169
|
+
elif kind in {"gate.opened", "gate.responded"}:
|
|
170
|
+
gate = gates.get(payload.get("id"))
|
|
171
|
+
if gate is None or agent not in owners(gate):
|
|
172
|
+
continue
|
|
173
|
+
if kind == "gate.opened":
|
|
174
|
+
shown = [readable(metas[i]) for i in gate.artifacts_to_show if i in metas]
|
|
175
|
+
items.append(
|
|
176
|
+
item(event, "gate", gate=gate.model_dump(mode="json"), artifacts=shown)
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
response = Gate.model_validate(payload).response
|
|
180
|
+
items.append(
|
|
181
|
+
item(
|
|
182
|
+
event,
|
|
183
|
+
"gate_response",
|
|
184
|
+
gate_id=gate.id,
|
|
185
|
+
title=gate.title,
|
|
186
|
+
status=payload.get("status"),
|
|
187
|
+
text=response.text if response else "",
|
|
188
|
+
chat=len((response.form if response else {}).get("chat_changes") or []),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
elif kind == "agent.tool" and event.agent == agent:
|
|
192
|
+
items.append(item(event, "progress", text=tool_text(payload)))
|
|
193
|
+
elif kind == "game.progress" and (director or actor(event) == agent):
|
|
194
|
+
items.append(item(event, "progress", text=str(payload.get("message", ""))))
|
|
195
|
+
elif kind == "artifact.created" and event.agent == agent:
|
|
196
|
+
fields = {k: payload.get(k) for k in ("id", "name", "mime")}
|
|
197
|
+
items.append(item(event, "artifact", artifact=fields))
|
|
198
|
+
elif director and kind in RUN_EVENTS:
|
|
199
|
+
items.append(item(event, "run", type=kind, message=payload.get("message")))
|
|
200
|
+
return items
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def artifact_metas(store: Store, run_id: str) -> dict[str, dict]:
|
|
204
|
+
"""id -> {id, name, mime} of every artifact of the run, without loading the data."""
|
|
205
|
+
with store.connect() as db:
|
|
206
|
+
rows = db.execute("SELECT id,name,mime FROM artifacts WHERE run_id=?", (run_id,))
|
|
207
|
+
return {r["id"]: dict(r) for r in rows}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def go_back_states(store: Store, run_id: str) -> dict[int, str]:
|
|
211
|
+
"""Each Go back card's state: "open", "going_back" (stop requested), "went_back" (the round
|
|
212
|
+
started again), "queued" (Queue for later) or "expired" (the round reached its gate or was
|
|
213
|
+
redone before an answer)."""
|
|
214
|
+
stops, declined, out, cards = {}, set(), {}, []
|
|
215
|
+
rewound, opened = [], []
|
|
216
|
+
for e in store.events(run_id):
|
|
217
|
+
if e.type == "chat.agent" and "rewind" in e.payload:
|
|
218
|
+
cards.append((e.id, e.payload["rewind"]["change"]))
|
|
219
|
+
elif e.type == "run.stop_requested":
|
|
220
|
+
stops.update(dict.fromkeys(e.payload.get("changes", []), e.id))
|
|
221
|
+
elif e.type == "rewind.declined":
|
|
222
|
+
declined.add(e.payload.get("change"))
|
|
223
|
+
elif e.type == "round.rewound":
|
|
224
|
+
rewound.append(e.id)
|
|
225
|
+
elif e.type == "gate.opened":
|
|
226
|
+
opened.append(e.id)
|
|
227
|
+
for card, change in cards:
|
|
228
|
+
if change in stops:
|
|
229
|
+
out[card] = "went_back" if any(r > stops[change] for r in rewound) else "going_back"
|
|
230
|
+
elif change in declined:
|
|
231
|
+
out[card] = "queued"
|
|
232
|
+
elif any(i > card for i in rewound + opened):
|
|
233
|
+
out[card] = "expired"
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def tool_text(payload: dict) -> str:
|
|
238
|
+
"""An agent's tool call as a line of its chat: 'Looked up with library.library_search
|
|
239
|
+
(query: slime)'."""
|
|
240
|
+
args = ", ".join(f"{k}: {v}" for k, v in (payload.get("arguments") or {}).items()
|
|
241
|
+
if str(v).strip())[:120] # fmt: skip
|
|
242
|
+
failed = "" if payload.get("ok", True) else " (it failed)"
|
|
243
|
+
return f"Looked up with {payload.get('tool', 'a tool')}" + (f" ({args})" if args else "") + failed
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def chat_fields(payload: dict) -> dict:
|
|
247
|
+
keys = ("text", "intent", "status", "error")
|
|
248
|
+
return {k: payload[k] for k in keys if k in payload}
|