god-code 0.2.2__tar.gz → 0.3.0__tar.gz
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.
- {god_code-0.2.2 → god_code-0.3.0}/PKG-INFO +1 -1
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/cli.py +46 -2
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/client.py +40 -2
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/engine.py +28 -6
- {god_code-0.2.2 → god_code-0.3.0}/pyproject.toml +1 -1
- {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_engine.py +37 -59
- {god_code-0.2.2 → god_code-0.3.0}/tests/test_e2e.py +9 -5
- {god_code-0.2.2 → god_code-0.3.0}/.github/workflows/publish.yml +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/.gitignore +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/CHANGELOG.md +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/CLAUDE.md +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/CONTRIBUTING.md +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/LICENSE +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/README.md +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/collision_planner.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/consistency_checker.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/dependency_graph.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/gdscript_linter.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/pattern_advisor.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/project.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/resource_validator.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/scene_parser.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/scene_writer.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/tscn_validator.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/streaming.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/vision.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/build_discipline.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/godot_playbook.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/knowledge_selector.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/system.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/py.typed +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/auth.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/config.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/context_manager.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/error_loop.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/oauth.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/session.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/base.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/file_ops.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/git.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/godot_cli.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/list_dir.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/registry.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/screenshot.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/search.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/shell.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_collision_planner.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_consistency.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_dependency_graph.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_linter.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_pattern_advisor.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_project.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_resource_validator.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_scene_parser.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_scene_writer.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_tscn_validator.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/llm/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/llm/test_client.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/llm/test_vision.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/prompts/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/prompts/test_knowledge_selector.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/prompts/test_system_prompt.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_config.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_context_manager.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_error_loop.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/__init__.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_file_ops.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_git.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_godot_cli.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_list_dir.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_registry.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_search.py +0 -0
- {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_shell.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: god-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AI coding agent specialized for Godot game development
|
|
5
5
|
Project-URL: Homepage, https://github.com/chuisiufai/god-code
|
|
6
6
|
Project-URL: Repository, https://github.com/chuisiufai/god-code
|
|
@@ -195,7 +195,7 @@ def _run_setup_wizard() -> None:
|
|
|
195
195
|
click.echo()
|
|
196
196
|
|
|
197
197
|
|
|
198
|
-
_VERSION = "0.
|
|
198
|
+
_VERSION = "0.3.0"
|
|
199
199
|
|
|
200
200
|
|
|
201
201
|
def _check_update() -> None:
|
|
@@ -345,6 +345,7 @@ def chat(project: str = ".", config: str | None = None):
|
|
|
345
345
|
cmd_table.add_row("/cd <path>", "change project directory")
|
|
346
346
|
cmd_table.add_row("/info", "show project details")
|
|
347
347
|
cmd_table.add_row("/status", "show model & auth")
|
|
348
|
+
cmd_table.add_row("/usage", "show token usage & cost")
|
|
348
349
|
cmd_table.add_row("/save", "save session")
|
|
349
350
|
cmd_table.add_row("/quit", "exit")
|
|
350
351
|
console.print(cmd_table)
|
|
@@ -398,6 +399,20 @@ def chat(project: str = ".", config: str | None = None):
|
|
|
398
399
|
console.print(f"[yellow] No project.godot in {project_root}[/]")
|
|
399
400
|
continue
|
|
400
401
|
|
|
402
|
+
if cmd == "/usage":
|
|
403
|
+
sess = engine.session_usage
|
|
404
|
+
cost = sess.cost_estimate(cfg.model)
|
|
405
|
+
usage_table = Table(show_header=False, box=None, padding=(0, 1))
|
|
406
|
+
usage_table.add_column(style="bold")
|
|
407
|
+
usage_table.add_column()
|
|
408
|
+
usage_table.add_row("Input tokens", f"{sess.prompt_tokens:,}")
|
|
409
|
+
usage_table.add_row("Output tokens", f"{sess.completion_tokens:,}")
|
|
410
|
+
usage_table.add_row("Total tokens", f"{sess.total_tokens:,}")
|
|
411
|
+
usage_table.add_row("API calls", str(engine.session_api_calls))
|
|
412
|
+
usage_table.add_row("Est. cost", f"${cost:.4f}")
|
|
413
|
+
console.print(Panel(usage_table, title="Usage", border_style="blue"))
|
|
414
|
+
continue
|
|
415
|
+
|
|
401
416
|
if cmd == "/status":
|
|
402
417
|
st = Table(show_header=False, box=None, padding=(0, 1))
|
|
403
418
|
st.add_column(style="bold")
|
|
@@ -446,12 +461,41 @@ def chat(project: str = ".", config: str | None = None):
|
|
|
446
461
|
border_style="cyan",
|
|
447
462
|
padding=(1, 2),
|
|
448
463
|
))
|
|
464
|
+
|
|
465
|
+
# Show token usage
|
|
466
|
+
turn = engine.last_turn
|
|
467
|
+
sess = engine.session_usage
|
|
468
|
+
if turn:
|
|
469
|
+
cost_turn = turn.usage.cost_estimate(cfg.model)
|
|
470
|
+
cost_sess = sess.cost_estimate(cfg.model)
|
|
471
|
+
tools_str = f" tools: {', '.join(turn.tools_called)}" if turn.tools_called else ""
|
|
472
|
+
console.print(
|
|
473
|
+
f" [dim]tokens: {turn.usage.total_tokens:,} "
|
|
474
|
+
f"(in:{turn.usage.prompt_tokens:,} out:{turn.usage.completion_tokens:,}) "
|
|
475
|
+
f"~${cost_turn:.4f}"
|
|
476
|
+
f"{tools_str}[/]"
|
|
477
|
+
)
|
|
478
|
+
console.print(
|
|
479
|
+
f" [dim]session: {sess.total_tokens:,} tokens "
|
|
480
|
+
f"| {engine.session_api_calls} API calls "
|
|
481
|
+
f"| ~${cost_sess:.4f} total[/]"
|
|
482
|
+
)
|
|
449
483
|
console.print()
|
|
450
484
|
finally:
|
|
451
485
|
await engine.close()
|
|
452
486
|
|
|
453
487
|
asyncio.run(_loop())
|
|
454
|
-
|
|
488
|
+
# Session summary
|
|
489
|
+
sess = engine.session_usage
|
|
490
|
+
cost = sess.cost_estimate(cfg.model)
|
|
491
|
+
console.print()
|
|
492
|
+
console.print(Panel(
|
|
493
|
+
f"Tokens: {sess.total_tokens:,} (in:{sess.prompt_tokens:,} out:{sess.completion_tokens:,})\n"
|
|
494
|
+
f"API calls: {engine.session_api_calls}\n"
|
|
495
|
+
f"Estimated cost: ${cost:.4f}",
|
|
496
|
+
title="[dim]Session Summary[/]",
|
|
497
|
+
border_style="dim",
|
|
498
|
+
))
|
|
455
499
|
|
|
456
500
|
|
|
457
501
|
@main.command()
|
|
@@ -7,11 +7,42 @@ import httpx
|
|
|
7
7
|
log = logging.getLogger(__name__)
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
@dataclass
|
|
11
|
+
class TokenUsage:
|
|
12
|
+
prompt_tokens: int = 0
|
|
13
|
+
completion_tokens: int = 0
|
|
14
|
+
total_tokens: int = 0
|
|
15
|
+
|
|
16
|
+
def __add__(self, other: TokenUsage) -> TokenUsage:
|
|
17
|
+
return TokenUsage(
|
|
18
|
+
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
|
|
19
|
+
completion_tokens=self.completion_tokens + other.completion_tokens,
|
|
20
|
+
total_tokens=self.total_tokens + other.total_tokens,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def cost_estimate(self, model: str = "") -> float:
|
|
24
|
+
"""Estimate cost in USD based on model pricing."""
|
|
25
|
+
# Pricing per 1M tokens (input/output)
|
|
26
|
+
pricing = {
|
|
27
|
+
"gpt-5.4": (2.50, 10.00),
|
|
28
|
+
"gpt-4o": (2.50, 10.00),
|
|
29
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
30
|
+
}
|
|
31
|
+
inp_rate, out_rate = pricing.get(model, (2.50, 10.00))
|
|
32
|
+
return (self.prompt_tokens * inp_rate + self.completion_tokens * out_rate) / 1_000_000
|
|
33
|
+
|
|
34
|
+
|
|
10
35
|
@dataclass
|
|
11
36
|
class ToolCall:
|
|
12
37
|
id: str
|
|
13
38
|
name: str
|
|
14
|
-
arguments: str
|
|
39
|
+
arguments: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ChatResponse:
|
|
44
|
+
message: Message
|
|
45
|
+
usage: TokenUsage
|
|
15
46
|
|
|
16
47
|
|
|
17
48
|
@dataclass
|
|
@@ -145,7 +176,14 @@ class LLMClient:
|
|
|
145
176
|
)
|
|
146
177
|
for tc in choice["tool_calls"]
|
|
147
178
|
]
|
|
148
|
-
|
|
179
|
+
msg = Message.assistant(content=choice.get("content"), tool_calls=tool_calls)
|
|
180
|
+
usage_data = data.get("usage", {})
|
|
181
|
+
usage = TokenUsage(
|
|
182
|
+
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
|
183
|
+
completion_tokens=usage_data.get("completion_tokens", 0),
|
|
184
|
+
total_tokens=usage_data.get("total_tokens", 0),
|
|
185
|
+
)
|
|
186
|
+
return ChatResponse(message=msg, usage=usage)
|
|
149
187
|
|
|
150
188
|
async def close(self):
|
|
151
189
|
await self._http.aclose()
|
|
@@ -4,8 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
7
8
|
|
|
8
|
-
from godot_agent.llm.client import LLMClient, Message
|
|
9
|
+
from godot_agent.llm.client import ChatResponse, LLMClient, Message, TokenUsage
|
|
9
10
|
from godot_agent.runtime.context_manager import compact_messages, estimate_tokens
|
|
10
11
|
from godot_agent.runtime.error_loop import format_validation_for_llm, validate_project
|
|
11
12
|
from godot_agent.tools.registry import ToolRegistry
|
|
@@ -13,11 +14,17 @@ from godot_agent.tools.registry import ToolRegistry
|
|
|
13
14
|
log = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
16
|
_COMPACT_THRESHOLD = 80000
|
|
16
|
-
|
|
17
|
-
# Tools that modify files — trigger Godot validation after execution
|
|
18
17
|
_FILE_MUTATING_TOOLS = {"write_file", "edit_file"}
|
|
19
18
|
|
|
20
19
|
|
|
20
|
+
@dataclass
|
|
21
|
+
class TurnStats:
|
|
22
|
+
"""Stats for a single submit() call (may include multiple LLM round-trips)."""
|
|
23
|
+
usage: TokenUsage = field(default_factory=TokenUsage)
|
|
24
|
+
api_calls: int = 0
|
|
25
|
+
tools_called: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
21
28
|
class ConversationEngine:
|
|
22
29
|
def __init__(
|
|
23
30
|
self,
|
|
@@ -34,6 +41,10 @@ class ConversationEngine:
|
|
|
34
41
|
self.messages: list[Message] = [Message.system(system_prompt)]
|
|
35
42
|
self.project_path = project_path
|
|
36
43
|
self.godot_path = godot_path
|
|
44
|
+
# Cumulative session stats
|
|
45
|
+
self.session_usage = TokenUsage()
|
|
46
|
+
self.session_api_calls = 0
|
|
47
|
+
self.last_turn: TurnStats | None = None
|
|
37
48
|
|
|
38
49
|
async def _maybe_compact(self) -> None:
|
|
39
50
|
total = sum(estimate_tokens(str(m.content or "")) for m in self.messages)
|
|
@@ -42,7 +53,6 @@ class ConversationEngine:
|
|
|
42
53
|
self.messages = compact_messages(self.messages, keep_recent=8)
|
|
43
54
|
|
|
44
55
|
async def _post_tool_validate(self, tool_names: set[str]) -> str | None:
|
|
45
|
-
"""Run Godot validation after file-mutating tools. Returns error report or None."""
|
|
46
56
|
if not self.project_path:
|
|
47
57
|
return None
|
|
48
58
|
if not tool_names & _FILE_MUTATING_TOOLS:
|
|
@@ -58,12 +68,23 @@ class ConversationEngine:
|
|
|
58
68
|
return None
|
|
59
69
|
|
|
60
70
|
async def _run_loop(self, tools: list[dict] | None) -> str:
|
|
71
|
+
turn = TurnStats()
|
|
72
|
+
|
|
61
73
|
for _ in range(self.max_tool_rounds + 1):
|
|
62
74
|
await self._maybe_compact()
|
|
63
|
-
|
|
75
|
+
chat_resp: ChatResponse = await self.client.chat(self.messages, tools)
|
|
76
|
+
response = chat_resp.message
|
|
77
|
+
|
|
78
|
+
# Track usage
|
|
79
|
+
turn.usage = turn.usage + chat_resp.usage
|
|
80
|
+
turn.api_calls += 1
|
|
81
|
+
self.session_usage = self.session_usage + chat_resp.usage
|
|
82
|
+
self.session_api_calls += 1
|
|
83
|
+
|
|
64
84
|
self.messages.append(response)
|
|
65
85
|
|
|
66
86
|
if not response.tool_calls:
|
|
87
|
+
self.last_turn = turn
|
|
67
88
|
return response.content or ""
|
|
68
89
|
|
|
69
90
|
tool_names_used: set[str] = set()
|
|
@@ -73,6 +94,7 @@ class ConversationEngine:
|
|
|
73
94
|
except json.JSONDecodeError:
|
|
74
95
|
args = {}
|
|
75
96
|
tool_names_used.add(tc.name)
|
|
97
|
+
turn.tools_called.append(tc.name)
|
|
76
98
|
result = await self.registry.execute(tc.name, args)
|
|
77
99
|
if result.error:
|
|
78
100
|
content = json.dumps({"error": result.error})
|
|
@@ -80,7 +102,6 @@ class ConversationEngine:
|
|
|
80
102
|
content = json.dumps(result.output.model_dump() if result.output else {})
|
|
81
103
|
self.messages.append(Message.tool_result(tool_call_id=tc.id, content=content))
|
|
82
104
|
|
|
83
|
-
# Auto-validate after file mutations
|
|
84
105
|
validation_report = await self._post_tool_validate(tool_names_used)
|
|
85
106
|
if validation_report:
|
|
86
107
|
self.messages.append(Message.user(
|
|
@@ -88,6 +109,7 @@ class ConversationEngine:
|
|
|
88
109
|
f"Fix the errors before proceeding."
|
|
89
110
|
))
|
|
90
111
|
|
|
112
|
+
self.last_turn = turn
|
|
91
113
|
return "Tool call limit reached. Please simplify the request."
|
|
92
114
|
|
|
93
115
|
async def submit(self, user_input: str) -> str:
|
|
@@ -2,26 +2,28 @@ import pytest
|
|
|
2
2
|
import json
|
|
3
3
|
from unittest.mock import AsyncMock
|
|
4
4
|
from godot_agent.runtime.engine import ConversationEngine
|
|
5
|
-
from godot_agent.llm.client import Message, ToolCall, LLMClient
|
|
5
|
+
from godot_agent.llm.client import Message, ToolCall, LLMClient, ChatResponse, TokenUsage
|
|
6
6
|
from godot_agent.tools.registry import ToolRegistry
|
|
7
7
|
from godot_agent.tools.base import BaseTool, ToolResult
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def _resp(msg: Message) -> ChatResponse:
|
|
12
|
+
"""Wrap a Message in a ChatResponse with dummy usage."""
|
|
13
|
+
return ChatResponse(message=msg, usage=TokenUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
|
|
14
|
+
|
|
15
|
+
|
|
11
16
|
class EchoInput(BaseModel):
|
|
12
17
|
text: str
|
|
13
18
|
|
|
14
|
-
|
|
15
19
|
class EchoOutput(BaseModel):
|
|
16
20
|
reply: str
|
|
17
21
|
|
|
18
|
-
|
|
19
22
|
class EchoTool(BaseTool):
|
|
20
23
|
name = "echo"
|
|
21
24
|
description = "Echo back"
|
|
22
25
|
Input = EchoInput
|
|
23
26
|
Output = EchoOutput
|
|
24
|
-
|
|
25
27
|
async def execute(self, input):
|
|
26
28
|
return ToolResult(output=EchoOutput(reply=f"echo: {input.text}"))
|
|
27
29
|
|
|
@@ -30,76 +32,48 @@ class TestConversationEngine:
|
|
|
30
32
|
@pytest.mark.asyncio
|
|
31
33
|
async def test_simple_text_response(self):
|
|
32
34
|
mock_client = AsyncMock(spec=LLMClient)
|
|
33
|
-
mock_client.chat = AsyncMock(return_value=Message.assistant(content="Hello!"))
|
|
35
|
+
mock_client.chat = AsyncMock(return_value=_resp(Message.assistant(content="Hello!")))
|
|
34
36
|
registry = ToolRegistry()
|
|
35
|
-
engine = ConversationEngine(
|
|
36
|
-
client=mock_client, registry=registry, system_prompt="You are helpful."
|
|
37
|
-
)
|
|
37
|
+
engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="You are helpful.")
|
|
38
38
|
response = await engine.submit("Hi")
|
|
39
39
|
assert response == "Hello!"
|
|
40
|
-
assert len(engine.messages) == 3
|
|
40
|
+
assert len(engine.messages) == 3
|
|
41
41
|
|
|
42
42
|
@pytest.mark.asyncio
|
|
43
43
|
async def test_tool_call_and_response(self):
|
|
44
44
|
registry = ToolRegistry()
|
|
45
45
|
registry.register(EchoTool())
|
|
46
|
-
call_msg = Message.assistant(
|
|
47
|
-
|
|
48
|
-
ToolCall(id="call_1", name="echo", arguments='{"text": "test"}')
|
|
49
|
-
]
|
|
50
|
-
)
|
|
51
|
-
final_msg = Message.assistant(content="The echo said: echo: test")
|
|
46
|
+
call_msg = _resp(Message.assistant(tool_calls=[ToolCall(id="call_1", name="echo", arguments='{"text": "test"}')]))
|
|
47
|
+
final_msg = _resp(Message.assistant(content="The echo said: echo: test"))
|
|
52
48
|
mock_client = AsyncMock(spec=LLMClient)
|
|
53
49
|
mock_client.chat = AsyncMock(side_effect=[call_msg, final_msg])
|
|
54
|
-
engine = ConversationEngine(
|
|
55
|
-
client=mock_client, registry=registry, system_prompt="test"
|
|
56
|
-
)
|
|
50
|
+
engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
|
|
57
51
|
response = await engine.submit("Echo something")
|
|
58
52
|
assert "echo: test" in response
|
|
59
|
-
# system + user + assistant(tool) + tool_result + assistant(final)
|
|
60
53
|
assert len(engine.messages) == 5
|
|
61
54
|
|
|
62
55
|
@pytest.mark.asyncio
|
|
63
56
|
async def test_max_turns_limit(self):
|
|
64
57
|
registry = ToolRegistry()
|
|
65
58
|
registry.register(EchoTool())
|
|
66
|
-
call_msg = Message.assistant(
|
|
67
|
-
tool_calls=[
|
|
68
|
-
ToolCall(id="call_1", name="echo", arguments='{"text": "loop"}')
|
|
69
|
-
]
|
|
70
|
-
)
|
|
59
|
+
call_msg = _resp(Message.assistant(tool_calls=[ToolCall(id="call_1", name="echo", arguments='{"text": "loop"}')]))
|
|
71
60
|
mock_client = AsyncMock(spec=LLMClient)
|
|
72
61
|
mock_client.chat = AsyncMock(return_value=call_msg)
|
|
73
|
-
engine = ConversationEngine(
|
|
74
|
-
client=mock_client,
|
|
75
|
-
registry=registry,
|
|
76
|
-
system_prompt="test",
|
|
77
|
-
max_tool_rounds=3,
|
|
78
|
-
)
|
|
62
|
+
engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test", max_tool_rounds=3)
|
|
79
63
|
response = await engine.submit("Loop forever")
|
|
80
|
-
# Should stop after max_tool_rounds + 1 iterations
|
|
81
64
|
assert mock_client.chat.call_count == 4
|
|
82
65
|
|
|
83
66
|
@pytest.mark.asyncio
|
|
84
67
|
async def test_tool_error_forwarded(self):
|
|
85
|
-
"""Tool execution errors are serialised as JSON error messages."""
|
|
86
68
|
registry = ToolRegistry()
|
|
87
69
|
registry.register(EchoTool())
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
tool_calls=[
|
|
91
|
-
ToolCall(id="call_err", name="echo", arguments='{"bad_key": 1}')
|
|
92
|
-
]
|
|
93
|
-
)
|
|
94
|
-
final_msg = Message.assistant(content="Got an error from the tool.")
|
|
70
|
+
call_msg = _resp(Message.assistant(tool_calls=[ToolCall(id="call_err", name="echo", arguments='{"bad_key": 1}')]))
|
|
71
|
+
final_msg = _resp(Message.assistant(content="Got an error from the tool."))
|
|
95
72
|
mock_client = AsyncMock(spec=LLMClient)
|
|
96
73
|
mock_client.chat = AsyncMock(side_effect=[call_msg, final_msg])
|
|
97
|
-
engine = ConversationEngine(
|
|
98
|
-
client=mock_client, registry=registry, system_prompt="test"
|
|
99
|
-
)
|
|
74
|
+
engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
|
|
100
75
|
response = await engine.submit("Break the tool")
|
|
101
76
|
assert response == "Got an error from the tool."
|
|
102
|
-
# Verify the tool_result message contains an error key
|
|
103
77
|
tool_result_msg = engine.messages[3]
|
|
104
78
|
assert tool_result_msg.role == "tool"
|
|
105
79
|
parsed = json.loads(tool_result_msg.content)
|
|
@@ -108,31 +82,35 @@ class TestConversationEngine:
|
|
|
108
82
|
@pytest.mark.asyncio
|
|
109
83
|
async def test_submit_with_images(self):
|
|
110
84
|
mock_client = AsyncMock(spec=LLMClient)
|
|
111
|
-
mock_client.chat = AsyncMock(
|
|
112
|
-
return_value=Message.assistant(content="I see an image.")
|
|
113
|
-
)
|
|
85
|
+
mock_client.chat = AsyncMock(return_value=_resp(Message.assistant(content="I see an image.")))
|
|
114
86
|
registry = ToolRegistry()
|
|
115
|
-
engine = ConversationEngine(
|
|
116
|
-
client=mock_client, registry=registry, system_prompt="Vision agent."
|
|
117
|
-
)
|
|
87
|
+
engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="Vision agent.")
|
|
118
88
|
response = await engine.submit_with_images("What is this?", ["base64data"])
|
|
119
89
|
assert response == "I see an image."
|
|
120
90
|
assert len(engine.messages) == 3
|
|
121
|
-
|
|
122
|
-
user_msg = engine.messages[1]
|
|
123
|
-
assert isinstance(user_msg.content, list)
|
|
91
|
+
assert isinstance(engine.messages[1].content, list)
|
|
124
92
|
|
|
125
93
|
@pytest.mark.asyncio
|
|
126
94
|
async def test_empty_registry_passes_no_tools(self):
|
|
127
95
|
mock_client = AsyncMock(spec=LLMClient)
|
|
128
|
-
mock_client.chat = AsyncMock(
|
|
129
|
-
return_value=Message.assistant(content="No tools here.")
|
|
130
|
-
)
|
|
96
|
+
mock_client.chat = AsyncMock(return_value=_resp(Message.assistant(content="No tools.")))
|
|
131
97
|
registry = ToolRegistry()
|
|
132
|
-
engine = ConversationEngine(
|
|
133
|
-
client=mock_client, registry=registry, system_prompt="test"
|
|
134
|
-
)
|
|
98
|
+
engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
|
|
135
99
|
await engine.submit("Hello")
|
|
136
|
-
# When registry is empty, tools param should be None
|
|
137
100
|
call_args = mock_client.chat.call_args
|
|
138
101
|
assert call_args[0][1] is None or call_args[1].get("tools") is None
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_usage_tracking(self):
|
|
105
|
+
mock_client = AsyncMock(spec=LLMClient)
|
|
106
|
+
mock_client.chat = AsyncMock(return_value=ChatResponse(
|
|
107
|
+
message=Message.assistant(content="Done"),
|
|
108
|
+
usage=TokenUsage(prompt_tokens=100, completion_tokens=50, total_tokens=150),
|
|
109
|
+
))
|
|
110
|
+
registry = ToolRegistry()
|
|
111
|
+
engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
|
|
112
|
+
await engine.submit("Test")
|
|
113
|
+
assert engine.session_usage.total_tokens == 150
|
|
114
|
+
assert engine.last_turn is not None
|
|
115
|
+
assert engine.last_turn.usage.total_tokens == 150
|
|
116
|
+
assert engine.session_api_calls == 1
|
|
@@ -12,7 +12,11 @@ from unittest.mock import AsyncMock
|
|
|
12
12
|
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
|
-
from godot_agent.llm.client import LLMClient, Message, ToolCall
|
|
15
|
+
from godot_agent.llm.client import LLMClient, Message, ToolCall, ChatResponse, TokenUsage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resp(msg: Message) -> ChatResponse:
|
|
19
|
+
return ChatResponse(message=msg, usage=TokenUsage())
|
|
16
20
|
from godot_agent.prompts.system import build_system_prompt
|
|
17
21
|
from godot_agent.runtime.engine import ConversationEngine
|
|
18
22
|
from godot_agent.tools.file_ops import EditFileTool, ReadFileTool, WriteFileTool
|
|
@@ -84,7 +88,7 @@ class TestE2EReadAndEdit:
|
|
|
84
88
|
final = Message.assistant(content="Done! Changed speed from 100 to 200.")
|
|
85
89
|
|
|
86
90
|
mock_client = AsyncMock(spec=LLMClient)
|
|
87
|
-
mock_client.chat = AsyncMock(side_effect=[read_call, edit_call, final])
|
|
91
|
+
mock_client.chat = AsyncMock(side_effect=[_resp(read_call), _resp(edit_call), _resp(final)])
|
|
88
92
|
|
|
89
93
|
engine = _make_engine(godot_project, mock_client)
|
|
90
94
|
result = await engine.submit("Change player speed to 200")
|
|
@@ -118,7 +122,7 @@ class TestE2ESearch:
|
|
|
118
122
|
)
|
|
119
123
|
|
|
120
124
|
mock_client = AsyncMock(spec=LLMClient)
|
|
121
|
-
mock_client.chat = AsyncMock(side_effect=[search_call, final])
|
|
125
|
+
mock_client.chat = AsyncMock(side_effect=[_resp(search_call), _resp(final)])
|
|
122
126
|
|
|
123
127
|
engine = _make_engine(godot_project, mock_client)
|
|
124
128
|
result = await engine.submit("Find where speed is defined")
|
|
@@ -148,7 +152,7 @@ class TestE2EWriteNewFile:
|
|
|
148
152
|
final = Message.assistant(content="Created enemy.gd with 50 HP.")
|
|
149
153
|
|
|
150
154
|
mock_client = AsyncMock(spec=LLMClient)
|
|
151
|
-
mock_client.chat = AsyncMock(side_effect=[write_call, final])
|
|
155
|
+
mock_client.chat = AsyncMock(side_effect=[_resp(write_call), _resp(final)])
|
|
152
156
|
|
|
153
157
|
engine = _make_engine(godot_project, mock_client)
|
|
154
158
|
result = await engine.submit("Create an enemy script")
|
|
@@ -189,7 +193,7 @@ class TestE2EMultiToolChain:
|
|
|
189
193
|
)
|
|
190
194
|
|
|
191
195
|
mock_client = AsyncMock(spec=LLMClient)
|
|
192
|
-
mock_client.chat = AsyncMock(side_effect=[glob_call, read_call, final])
|
|
196
|
+
mock_client.chat = AsyncMock(side_effect=[_resp(glob_call), _resp(read_call), _resp(final)])
|
|
193
197
|
|
|
194
198
|
engine = _make_engine(godot_project, mock_client)
|
|
195
199
|
result = await engine.submit("List all scripts and show me the player")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|