caigode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
caigode/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """caigode package."""
@@ -0,0 +1,475 @@
1
+ """Application service for one coding-agent execution turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Protocol
11
+
12
+ from caigode.application.tool_runtime import (
13
+ ToolCall,
14
+ ToolRuntime,
15
+ list_top_level_entries,
16
+ workspace_root,
17
+ )
18
+ from caigode.domain.task import (
19
+ AgentTurnResult,
20
+ TaskIntent,
21
+ ToolAction,
22
+ VerificationResult,
23
+ )
24
+ from caigode.infra.openai_client import OpenAIAPIError
25
+
26
+ SUMMARY_CHAR_BUDGET = 24000
27
+
28
+
29
+ class AgentPlanError(ValueError):
30
+ """Raised when the model response cannot be mapped to a tool plan."""
31
+
32
+
33
+ class ModelClient(Protocol):
34
+ """Minimal model-client contract consumed by the agent service."""
35
+
36
+ def create_chat_completion(self, *, messages: list[dict[str, str]]) -> Any:
37
+ """Return an object with a string ``content`` attribute."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AgentTurnPlan:
42
+ """One model response normalized for execution."""
43
+
44
+ summary: str | None
45
+ writes: tuple[dict[str, str], ...]
46
+ tool_calls: tuple[ToolCall, ...]
47
+ done: bool | None
48
+
49
+
50
+ @dataclass
51
+ class AgentService:
52
+ """Coordinate model planning, local file changes, and verification."""
53
+
54
+ model_client: ModelClient
55
+ workspace: Any
56
+ shell_runner: Any
57
+ _messages: list[dict[str, str]] = field(default_factory=list, init=False)
58
+ _history_compaction_count: int = field(default=0, init=False)
59
+
60
+ def run_turn(self, intent: TaskIntent) -> AgentTurnResult:
61
+ """Execute one model-guided turn and return structured results."""
62
+
63
+ runtime_context = self._build_runtime_context()
64
+ self._ensure_session_initialized(runtime_context)
65
+
66
+ context_payloads, read_actions = self._load_context(intent.context_files)
67
+ tool_actions = list(read_actions)
68
+ runtime = ToolRuntime(workspace=self.workspace, shell_runner=self.shell_runner)
69
+ user_message = {
70
+ "role": "user",
71
+ "content": self._build_user_prompt(intent, context_payloads, runtime_context),
72
+ }
73
+ self._messages.append(user_message)
74
+
75
+ try:
76
+ summary, raw_response = self._run_agent_loop(runtime, tool_actions)
77
+ except Exception as exc:
78
+ if not _is_ooc_error(exc):
79
+ raise
80
+ self._compact_session_for_ooc(runtime_context)
81
+ self._messages.append(user_message)
82
+ summary, raw_response = self._run_agent_loop(runtime, tool_actions)
83
+
84
+ verification_results: list[VerificationResult] = []
85
+ for command in intent.verification_commands:
86
+ result = self.shell_runner.run(command)
87
+ verification_results.append(
88
+ VerificationResult(
89
+ command=command,
90
+ returncode=result.returncode,
91
+ stdout=result.stdout,
92
+ stderr=result.stderr,
93
+ )
94
+ )
95
+ tool_actions.append(
96
+ ToolAction(
97
+ kind="verify",
98
+ target=command,
99
+ detail="verification command",
100
+ exit_code=result.returncode,
101
+ stdout=result.stdout,
102
+ stderr=result.stderr,
103
+ )
104
+ )
105
+
106
+ return AgentTurnResult(
107
+ prompt=intent.prompt,
108
+ summary=summary,
109
+ raw_response=raw_response,
110
+ tool_actions=tuple(tool_actions),
111
+ verification_results=tuple(verification_results),
112
+ )
113
+
114
+ def export_messages(self) -> tuple[dict[str, str], ...]:
115
+ return tuple(dict(item) for item in self._messages)
116
+
117
+ def import_messages(self, messages: tuple[dict[str, str], ...]) -> None:
118
+ self._messages = [
119
+ {"role": str(item.get("role", "")), "content": str(item.get("content", ""))}
120
+ for item in messages
121
+ if isinstance(item, dict)
122
+ ]
123
+
124
+ def _run_agent_loop(
125
+ self,
126
+ runtime: ToolRuntime,
127
+ tool_actions: list[ToolAction],
128
+ ) -> tuple[str, str]:
129
+ while True:
130
+ response = self.model_client.create_chat_completion(messages=self._messages)
131
+ raw_response = str(getattr(response, "content", ""))
132
+ self._messages.append({"role": "assistant", "content": raw_response})
133
+ plan = _parse_plan(raw_response)
134
+
135
+ tool_results: list[dict[str, Any]] = []
136
+ for write in plan.writes:
137
+ tool_results.append(
138
+ runtime.execute_write_file(
139
+ path=write["path"],
140
+ content=write["content"],
141
+ tool_actions=tool_actions,
142
+ )
143
+ )
144
+ for tool_call in plan.tool_calls:
145
+ tool_results.append(runtime.execute_tool_call(tool_call, tool_actions))
146
+
147
+ if tool_results:
148
+ self._messages.append(
149
+ {"role": "user", "content": _build_tool_results_prompt(tool_results)}
150
+ )
151
+
152
+ should_finish = plan.done if plan.done is not None else not plan.tool_calls
153
+ if should_finish:
154
+ return plan.summary or _fallback_summary(tool_results), raw_response
155
+
156
+ if not plan.tool_calls and not plan.writes:
157
+ raise AgentPlanError(
158
+ "Model requested continuation without tool_calls or writes"
159
+ )
160
+
161
+ def _load_context(
162
+ self, context_files: tuple[str, ...]
163
+ ) -> tuple[list[dict[str, str]], list[ToolAction]]:
164
+ payloads: list[dict[str, str]] = []
165
+ actions: list[ToolAction] = []
166
+ for path in context_files:
167
+ read_result = self.workspace.read_text(path)
168
+ payloads.append({"path": path, "content": read_result.content})
169
+ actions.append(
170
+ ToolAction(
171
+ kind="read",
172
+ target=str(read_result.path),
173
+ detail=f"{len(read_result.content)} chars",
174
+ )
175
+ )
176
+ return payloads, actions
177
+
178
+ def _ensure_session_initialized(self, runtime_context: dict[str, Any]) -> None:
179
+ if self._messages:
180
+ return
181
+ self._messages = [
182
+ {"role": "system", "content": _build_system_prompt(runtime_context)}
183
+ ]
184
+
185
+ def _build_runtime_context(self) -> dict[str, Any]:
186
+ root = workspace_root(self.workspace)
187
+ return {
188
+ "identity": "caigode",
189
+ "python_version": platform.python_version(),
190
+ "platform": platform.platform(),
191
+ "workspace_root": str(root) if root is not None else None,
192
+ "cwd": str(root) if root is not None else None,
193
+ "top_level_entries": list_top_level_entries(root),
194
+ "git": _collect_git_context(root),
195
+ }
196
+
197
+ def _build_user_prompt(
198
+ self,
199
+ intent: TaskIntent,
200
+ context_payloads: list[dict[str, str]],
201
+ runtime_context: dict[str, Any],
202
+ ) -> str:
203
+ payload = {
204
+ "task": intent.prompt,
205
+ "runtime_context": runtime_context,
206
+ "context_files": context_payloads,
207
+ "recent_messages": _recent_messages(self._messages),
208
+ "available_tools": [
209
+ {
210
+ "name": "list_dir",
211
+ "args": {
212
+ "path": "relative/or/absolute/path (optional, default '.')",
213
+ "recursive": "bool (optional, default false)",
214
+ "max_entries": "int (optional, default 200, max 1000)",
215
+ },
216
+ },
217
+ {
218
+ "name": "read_file",
219
+ "args": {
220
+ "path": "relative/or/absolute/path",
221
+ "offset": "int >= 0 (optional)",
222
+ "limit": "int >= 0 (optional)",
223
+ "start_line": "int >= 1 (optional)",
224
+ "end_line": "int >= start_line (optional)",
225
+ },
226
+ },
227
+ {
228
+ "name": "write_file",
229
+ "args": {
230
+ "path": "relative/or/absolute/path",
231
+ "content": "full file content",
232
+ },
233
+ },
234
+ {
235
+ "name": "run_command",
236
+ "args": {"command": "shell command executed in workspace root"},
237
+ },
238
+ ],
239
+ "required_response_schema": {
240
+ "summary": "string (required when done=true or when no tool_calls)",
241
+ "writes": [
242
+ {"path": "relative/path.txt", "content": "full file content"}
243
+ ],
244
+ "tool_calls": [
245
+ {"tool": "tool_name", "args": {"key": "value"}}
246
+ ],
247
+ "done": "boolean (optional)",
248
+ },
249
+ }
250
+ return json.dumps(payload, ensure_ascii=False, indent=2)
251
+
252
+ def _compact_session_for_ooc(self, runtime_context: dict[str, Any]) -> None:
253
+ summary = self._summarize_history_with_model()
254
+ self._history_compaction_count += 1
255
+ self._messages = [
256
+ {"role": "system", "content": _build_system_prompt(runtime_context)},
257
+ {
258
+ "role": "system",
259
+ "content": (
260
+ "Conversation was compacted due to out-of-context. "
261
+ f"Compaction #{self._history_compaction_count}. "
262
+ "Use this summary as prior context:\n"
263
+ f"{summary}"
264
+ ),
265
+ },
266
+ ]
267
+
268
+ def _summarize_history_with_model(self) -> str:
269
+ transcript = _render_transcript_for_summary(self._messages)
270
+ prompt_messages = [
271
+ {
272
+ "role": "system",
273
+ "content": (
274
+ "Summarize the coding conversation for continuation.\n"
275
+ "Keep concrete facts: goals, decisions, files edited, open tasks, errors.\n"
276
+ "Output plain text only."
277
+ ),
278
+ },
279
+ {"role": "user", "content": transcript},
280
+ ]
281
+ try:
282
+ response = self.model_client.create_chat_completion(messages=prompt_messages)
283
+ except Exception:
284
+ return _fallback_history_summary(self._messages)
285
+ content = str(getattr(response, "content", "")).strip()
286
+ if not content:
287
+ return _fallback_history_summary(self._messages)
288
+ return content
289
+
290
+
291
+ def _build_system_prompt(runtime_context: dict[str, Any]) -> str:
292
+ return (
293
+ "You are caigode, a coding agent running in a local terminal workspace.\n"
294
+ "This chat session is stateful: you can and must use prior conversation messages.\n"
295
+ "Never claim you are stateless or unable to access earlier turns in this same session.\n"
296
+ "Use runtime_context as ground truth for where you are running.\n"
297
+ "If the user asks to edit files in 'this folder', treat workspace_root/cwd as that folder.\n"
298
+ "To inspect files, call tools (list_dir/read_file) instead of asking for paths first.\n"
299
+ "Return strict JSON only.\n"
300
+ "You can return legacy writes, or prefer tool_calls with optional done.\n"
301
+ f"Runtime context snapshot:\n{json.dumps(runtime_context, ensure_ascii=False, indent=2)}"
302
+ )
303
+
304
+
305
+ def _build_tool_results_prompt(results: list[dict[str, Any]]) -> str:
306
+ return json.dumps(
307
+ {
308
+ "tool_results": results,
309
+ "instruction": (
310
+ "If the task is complete, return final summary with done=true. "
311
+ "If more work is needed, return next tool_calls with done=false."
312
+ ),
313
+ },
314
+ ensure_ascii=False,
315
+ indent=2,
316
+ )
317
+
318
+
319
+ def _parse_plan(raw_response: str) -> AgentTurnPlan:
320
+ payload = _load_json(raw_response)
321
+ summary = payload.get("summary")
322
+ writes = payload.get("writes", [])
323
+ tool_calls = payload.get("tool_calls", [])
324
+ done = payload.get("done")
325
+
326
+ if summary is not None:
327
+ if not isinstance(summary, str) or not summary.strip():
328
+ raise AgentPlanError("summary must be a non-empty string when provided")
329
+ summary = summary.strip()
330
+
331
+ if not isinstance(writes, list):
332
+ raise AgentPlanError("writes must be a list")
333
+ if not isinstance(tool_calls, list):
334
+ raise AgentPlanError("tool_calls must be a list")
335
+ if done is not None and not isinstance(done, bool):
336
+ raise AgentPlanError("done must be a boolean when provided")
337
+
338
+ normalized_writes: list[dict[str, str]] = []
339
+ for item in writes:
340
+ if not isinstance(item, dict):
341
+ raise AgentPlanError("Each write entry must be an object")
342
+ path = item.get("path")
343
+ content = item.get("content")
344
+ if not isinstance(path, str) or not path.strip():
345
+ raise AgentPlanError("Each write entry must include a non-empty path")
346
+ if not isinstance(content, str):
347
+ raise AgentPlanError("Each write entry must include string content")
348
+ normalized_writes.append({"path": path, "content": content})
349
+
350
+ normalized_calls: list[ToolCall] = []
351
+ for item in tool_calls:
352
+ if not isinstance(item, dict):
353
+ raise AgentPlanError("Each tool_call entry must be an object")
354
+ tool = item.get("tool")
355
+ args = item.get("args", {})
356
+ if not isinstance(tool, str) or not tool.strip():
357
+ raise AgentPlanError("Each tool_call must include a non-empty tool")
358
+ if not isinstance(args, dict):
359
+ raise AgentPlanError("Each tool_call args must be an object")
360
+ normalized_calls.append(ToolCall(tool=tool.strip(), args=args))
361
+
362
+ if summary is None and not normalized_writes and not normalized_calls:
363
+ raise AgentPlanError("Model response must include summary, writes, or tool_calls")
364
+
365
+ return AgentTurnPlan(
366
+ summary=summary,
367
+ writes=tuple(normalized_writes),
368
+ tool_calls=tuple(normalized_calls),
369
+ done=done,
370
+ )
371
+
372
+
373
+ def _load_json(raw_response: str) -> dict[str, Any]:
374
+ candidate = raw_response.strip()
375
+ if candidate.startswith("```"):
376
+ lines = candidate.splitlines()
377
+ if len(lines) >= 3:
378
+ candidate = "\n".join(lines[1:-1]).strip()
379
+ try:
380
+ payload = json.loads(candidate)
381
+ except json.JSONDecodeError as exc:
382
+ raise AgentPlanError("Model response was not valid JSON") from exc
383
+ if not isinstance(payload, dict):
384
+ raise AgentPlanError("Model response JSON must be an object")
385
+ return payload
386
+
387
+
388
+ def _collect_git_context(root: Path | None) -> dict[str, Any]:
389
+ if root is None:
390
+ return {"inside_worktree": False}
391
+ inside = _run_git(root, "rev-parse", "--is-inside-work-tree")
392
+ if inside.returncode != 0 or inside.stdout.strip() != "true":
393
+ return {"inside_worktree": False}
394
+ branch = _run_git(root, "rev-parse", "--abbrev-ref", "HEAD")
395
+ status = _run_git(root, "status", "--short")
396
+ return {
397
+ "inside_worktree": True,
398
+ "branch": branch.stdout.strip() if branch.returncode == 0 else None,
399
+ "status_short": status.stdout.splitlines()[:20] if status.returncode == 0 else [],
400
+ }
401
+
402
+
403
+ def _run_git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
404
+ return subprocess.run(
405
+ ("git", *args),
406
+ cwd=root,
407
+ capture_output=True,
408
+ text=True,
409
+ check=False,
410
+ )
411
+
412
+
413
+ def _is_ooc_error(exc: Exception) -> bool:
414
+ if isinstance(exc, OpenAIAPIError):
415
+ lowered = exc.message.lower()
416
+ if exc.status_code in {400, 413} and (
417
+ "context" in lowered
418
+ or "token" in lowered
419
+ or "maximum" in lowered
420
+ or "too long" in lowered
421
+ ):
422
+ return True
423
+ message = str(exc).lower()
424
+ if "out of context" in message or "context length" in message:
425
+ return True
426
+ return False
427
+
428
+
429
+ def _render_transcript_for_summary(messages: list[dict[str, str]]) -> str:
430
+ lines: list[str] = []
431
+ current = 0
432
+ for msg in reversed(messages):
433
+ role = msg.get("role", "unknown")
434
+ content = msg.get("content", "")
435
+ rendered = f"[{role}]\n{content}\n"
436
+ if current + len(rendered) > SUMMARY_CHAR_BUDGET:
437
+ break
438
+ lines.append(rendered)
439
+ current += len(rendered)
440
+ lines.reverse()
441
+ return "\n".join(lines)
442
+
443
+
444
+ def _fallback_history_summary(messages: list[dict[str, str]]) -> str:
445
+ turns = sum(1 for msg in messages if msg.get("role") == "user")
446
+ return f"Conversation with {turns} user turns. Resume from latest user request."
447
+
448
+
449
+ def _fallback_summary(tool_results: list[dict[str, Any]]) -> str:
450
+ if not tool_results:
451
+ return "No changes were required."
452
+ success_count = sum(1 for item in tool_results if item.get("ok") is True)
453
+ failure_count = sum(1 for item in tool_results if item.get("ok") is False)
454
+ return (
455
+ f"Executed {len(tool_results)} action(s): "
456
+ f"{success_count} succeeded, {failure_count} failed."
457
+ )
458
+
459
+
460
+ def _recent_messages(
461
+ messages: list[dict[str, str]],
462
+ *,
463
+ limit: int = 8,
464
+ content_chars: int = 500,
465
+ ) -> list[dict[str, str]]:
466
+ selected = [item for item in messages if item.get("role") in {"user", "assistant"}]
467
+ selected = selected[-limit:]
468
+ output: list[dict[str, str]] = []
469
+ for item in selected:
470
+ role = str(item.get("role", ""))
471
+ content = str(item.get("content", ""))
472
+ if len(content) > content_chars:
473
+ content = content[:content_chars] + "\n...<truncated>..."
474
+ output.append({"role": role, "content": content})
475
+ return output