codex-autorunner 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. codex_autorunner/agents/__init__.py +20 -0
  2. codex_autorunner/agents/base.py +2 -2
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/execution/policy.py +292 -0
  5. codex_autorunner/agents/factory.py +52 -0
  6. codex_autorunner/agents/opencode/__init__.py +4 -0
  7. codex_autorunner/agents/opencode/agent_config.py +104 -0
  8. codex_autorunner/agents/opencode/client.py +271 -27
  9. codex_autorunner/agents/opencode/harness.py +71 -20
  10. codex_autorunner/agents/opencode/logging.py +209 -0
  11. codex_autorunner/agents/opencode/run_prompt.py +260 -0
  12. codex_autorunner/agents/opencode/runtime.py +1106 -124
  13. codex_autorunner/agents/opencode/supervisor.py +161 -23
  14. codex_autorunner/agents/orchestrator.py +358 -0
  15. codex_autorunner/agents/registry.py +130 -0
  16. codex_autorunner/agents/types.py +2 -2
  17. codex_autorunner/bootstrap.py +3 -5
  18. codex_autorunner/cli.py +77 -12
  19. codex_autorunner/core/app_server_events.py +15 -6
  20. codex_autorunner/core/app_server_logging.py +48 -12
  21. codex_autorunner/core/app_server_prompts.py +2 -0
  22. codex_autorunner/core/circuit_breaker.py +183 -0
  23. codex_autorunner/core/config.py +167 -35
  24. codex_autorunner/core/doc_chat.py +51 -20
  25. codex_autorunner/core/docs.py +44 -7
  26. codex_autorunner/core/engine.py +730 -192
  27. codex_autorunner/core/exceptions.py +60 -0
  28. codex_autorunner/core/hub.py +9 -7
  29. codex_autorunner/core/locks.py +114 -1
  30. codex_autorunner/core/logging_utils.py +9 -6
  31. codex_autorunner/core/path_utils.py +123 -0
  32. codex_autorunner/core/retry.py +61 -0
  33. codex_autorunner/core/review.py +888 -0
  34. codex_autorunner/core/review_context.py +164 -0
  35. codex_autorunner/core/run_index.py +217 -0
  36. codex_autorunner/core/runner_controller.py +44 -1
  37. codex_autorunner/core/runner_process.py +27 -1
  38. codex_autorunner/core/sqlite_utils.py +32 -0
  39. codex_autorunner/core/state.py +359 -42
  40. codex_autorunner/core/text_delta_coalescer.py +43 -0
  41. codex_autorunner/core/usage.py +107 -75
  42. codex_autorunner/core/utils.py +138 -1
  43. codex_autorunner/discovery.py +5 -3
  44. codex_autorunner/integrations/app_server/client.py +144 -73
  45. codex_autorunner/integrations/telegram/adapter.py +356 -41
  46. codex_autorunner/integrations/telegram/config.py +64 -1
  47. codex_autorunner/integrations/telegram/constants.py +3 -0
  48. codex_autorunner/integrations/telegram/dispatch.py +27 -8
  49. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  50. codex_autorunner/integrations/telegram/handlers/callbacks.py +19 -1
  51. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +27 -0
  52. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  53. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2599 -0
  54. codex_autorunner/integrations/telegram/handlers/commands/files.py +1412 -0
  55. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  56. codex_autorunner/integrations/telegram/handlers/commands/github.py +2229 -0
  57. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  58. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  59. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  60. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +763 -5156
  61. codex_autorunner/integrations/telegram/handlers/messages.py +263 -49
  62. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  63. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  64. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  65. codex_autorunner/integrations/telegram/helpers.py +2 -2
  66. codex_autorunner/integrations/telegram/notifications.py +126 -35
  67. codex_autorunner/integrations/telegram/outbox.py +8 -8
  68. codex_autorunner/integrations/telegram/progress_stream.py +39 -9
  69. codex_autorunner/integrations/telegram/runtime.py +24 -13
  70. codex_autorunner/integrations/telegram/service.py +294 -97
  71. codex_autorunner/integrations/telegram/state.py +1180 -330
  72. codex_autorunner/integrations/telegram/transport.py +1 -1
  73. codex_autorunner/integrations/telegram/types.py +22 -2
  74. codex_autorunner/integrations/telegram/voice.py +14 -15
  75. codex_autorunner/routes/__init__.py +2 -0
  76. codex_autorunner/routes/agents.py +18 -78
  77. codex_autorunner/routes/base.py +75 -18
  78. codex_autorunner/routes/repos.py +17 -0
  79. codex_autorunner/routes/review.py +148 -0
  80. codex_autorunner/routes/runs.py +133 -1
  81. codex_autorunner/routes/sessions.py +16 -8
  82. codex_autorunner/routes/settings.py +22 -0
  83. codex_autorunner/routes/shared.py +33 -3
  84. codex_autorunner/routes/system.py +16 -0
  85. codex_autorunner/routes/voice.py +5 -13
  86. codex_autorunner/spec_ingest.py +33 -9
  87. codex_autorunner/static/agentControls.js +8 -1
  88. codex_autorunner/static/app.js +2 -0
  89. codex_autorunner/static/dashboard.js +55 -9
  90. codex_autorunner/static/docChatActions.js +8 -0
  91. codex_autorunner/static/docsInit.js +13 -2
  92. codex_autorunner/static/github.js +79 -17
  93. codex_autorunner/static/hub.js +26 -8
  94. codex_autorunner/static/index.html +68 -16
  95. codex_autorunner/static/liveUpdates.js +58 -0
  96. codex_autorunner/static/logs.js +67 -29
  97. codex_autorunner/static/review.js +157 -0
  98. codex_autorunner/static/runs.js +64 -55
  99. codex_autorunner/static/settings.js +6 -0
  100. codex_autorunner/static/state.js +9 -1
  101. codex_autorunner/static/styles.css +117 -0
  102. codex_autorunner/static/terminalManager.js +19 -0
  103. codex_autorunner/static/voice.js +20 -7
  104. codex_autorunner/voice/capture.py +7 -7
  105. codex_autorunner/voice/service.py +51 -9
  106. codex_autorunner/web/app.py +159 -117
  107. codex_autorunner/web/hub_jobs.py +13 -2
  108. codex_autorunner/web/middleware.py +48 -13
  109. codex_autorunner/web/pty_session.py +26 -13
  110. codex_autorunner/web/schemas.py +25 -0
  111. codex_autorunner/web/static_assets.py +56 -0
  112. codex_autorunner/web/static_refresh.py +86 -0
  113. {codex_autorunner-0.1.1.dist-info → codex_autorunner-0.1.2.dist-info}/METADATA +3 -1
  114. {codex_autorunner-0.1.1.dist-info → codex_autorunner-0.1.2.dist-info}/RECORD +119 -88
  115. {codex_autorunner-0.1.1.dist-info → codex_autorunner-0.1.2.dist-info}/WHEEL +1 -1
  116. /codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +0 -0
  117. {codex_autorunner-0.1.1.dist-info → codex_autorunner-0.1.2.dist-info}/entry_points.txt +0 -0
  118. {codex_autorunner-0.1.1.dist-info → codex_autorunner-0.1.2.dist-info}/licenses/LICENSE +0 -0
  119. {codex_autorunner-0.1.1.dist-info → codex_autorunner-0.1.2.dist-info}/top_level.txt +0 -0
@@ -1 +1,21 @@
1
1
  """Agent harness abstractions."""
2
+
3
+ from .registry import (
4
+ AgentCapability,
5
+ AgentDescriptor,
6
+ get_agent_descriptor,
7
+ get_available_agents,
8
+ get_registered_agents,
9
+ has_capability,
10
+ validate_agent_id,
11
+ )
12
+
13
+ __all__ = [
14
+ "AgentCapability",
15
+ "AgentDescriptor",
16
+ "get_registered_agents",
17
+ "get_available_agents",
18
+ "get_agent_descriptor",
19
+ "validate_agent_id",
20
+ "has_capability",
21
+ ]
@@ -3,11 +3,11 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any, AsyncIterator, Optional, Protocol
5
5
 
6
- from .types import ConversationRef, ModelCatalog, TurnRef
6
+ from .types import AgentId, ConversationRef, ModelCatalog, TurnRef
7
7
 
8
8
 
9
9
  class AgentHarness(Protocol):
10
- agent_id: str
10
+ agent_id: AgentId
11
11
  display_name: str
12
12
 
13
13
  async def ensure_ready(self, workspace_root: Path) -> None: ...
@@ -73,7 +73,7 @@ def _coerce_reasoning_efforts(entry: dict[str, Any]) -> list[str]:
73
73
 
74
74
 
75
75
  class CodexHarness(AgentHarness):
76
- agent_id: AgentId = "codex"
76
+ agent_id: AgentId = AgentId("codex")
77
77
  display_name = "Codex"
78
78
 
79
79
  def __init__(
@@ -0,0 +1,292 @@
1
+ """Centralized approval and sandbox policy mappings for Codex and OpenCode agents."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional, Union
5
+
6
+ # ========================================================================
7
+ # Approval Policies
8
+ # ========================================================================
9
+
10
+
11
+ class ApprovalPolicy:
12
+ """Canonical approval policy values for both Codex and OpenCode."""
13
+
14
+ NEVER = "never"
15
+ ON_FAILURE = "on-failure"
16
+ ON_REQUEST = "on-request"
17
+ UNTRUSTED = "untrusted"
18
+
19
+ ALL_VALUES = {NEVER, ON_FAILURE, ON_REQUEST, UNTRUSTED}
20
+
21
+
22
+ # ========================================================================
23
+ # Sandbox Policies (Codex)
24
+ # ========================================================================
25
+
26
+
27
+ class SandboxPolicy:
28
+ """Canonical sandbox policy values for Codex app-server."""
29
+
30
+ DANGER_FULL_ACCESS = "dangerFullAccess"
31
+ READ_ONLY = "readOnly"
32
+ WORKSPACE_WRITE = "workspaceWrite"
33
+ EXTERNAL_SANDBOX = "externalSandbox"
34
+
35
+ ALL_VALUES = {
36
+ DANGER_FULL_ACCESS,
37
+ READ_ONLY,
38
+ WORKSPACE_WRITE,
39
+ EXTERNAL_SANDBOX,
40
+ }
41
+
42
+
43
+ # ========================================================================
44
+ # Permission Policies (OpenCode)
45
+ # ========================================================================
46
+
47
+
48
+ class PermissionPolicy:
49
+ """Canonical permission policy values for OpenCode."""
50
+
51
+ ALLOW = "allow"
52
+ DENY = "deny"
53
+ ASK = "ask"
54
+
55
+ ALL_VALUES = {ALLOW, DENY, ASK}
56
+
57
+
58
+ # ========================================================================
59
+ # Data Classes
60
+ # ========================================================================
61
+
62
+
63
+ @dataclass
64
+ class SandboxPolicyConfig:
65
+ """Configuration for Codex sandbox policies."""
66
+
67
+ policy: Union[str, dict[str, Any]]
68
+ """Either a string policy type or full policy dict with type and options."""
69
+
70
+
71
+ @dataclass
72
+ class PolicyMapping:
73
+ """Unified policy mapping for both Codex and OpenCode agents."""
74
+
75
+ approval_policy: str
76
+ sandbox_policy: Union[str, dict[str, Any]]
77
+ permission_policy: Optional[str] = None
78
+
79
+
80
+ # ========================================================================
81
+ # Normalization Functions
82
+ # ========================================================================
83
+
84
+
85
+ def normalize_approval_policy(policy: Optional[str]) -> str:
86
+ """Normalize approval policy to canonical value.
87
+
88
+ Args:
89
+ policy: Approval policy string (case-insensitive, various aliases accepted).
90
+
91
+ Returns:
92
+ Canonical approval policy value.
93
+
94
+ Raises:
95
+ ValueError: If policy is not a recognized value.
96
+ """
97
+ if policy is None:
98
+ return ApprovalPolicy.NEVER
99
+
100
+ if not isinstance(policy, str):
101
+ raise ValueError(f"Invalid approval policy: {policy!r}")
102
+
103
+ normalized = policy.strip()
104
+ if not normalized:
105
+ raise ValueError(f"Invalid approval policy: {policy!r}")
106
+
107
+ normalized = normalized.lower()
108
+
109
+ # Aliases for never
110
+ if normalized in ("never", "no", "false", "0"):
111
+ return ApprovalPolicy.NEVER
112
+
113
+ # Aliases for on-failure
114
+ if normalized in (
115
+ "on-failure",
116
+ "on_failure",
117
+ "onfailure",
118
+ "fail",
119
+ "failure",
120
+ ):
121
+ return ApprovalPolicy.ON_FAILURE
122
+
123
+ # Aliases for on-request
124
+ if normalized in ("on-request", "on_request", "onrequest", "ask", "prompt"):
125
+ return ApprovalPolicy.ON_REQUEST
126
+
127
+ # Aliases for untrusted
128
+ if normalized in (
129
+ "untrusted",
130
+ "unlesstrusted",
131
+ "unless-trusted",
132
+ "unless trusted",
133
+ "auto",
134
+ ):
135
+ return ApprovalPolicy.UNTRUSTED
136
+
137
+ raise ValueError(
138
+ f"Invalid approval policy: {policy!r}. "
139
+ f"Valid values: {', '.join(sorted(ApprovalPolicy.ALL_VALUES))}"
140
+ )
141
+
142
+
143
+ def normalize_sandbox_policy(policy: Optional[Any]) -> Union[str, dict[str, Any]]:
144
+ """Normalize sandbox policy to canonical value.
145
+
146
+ Args:
147
+ policy: Sandbox policy (string or dict with 'type' field).
148
+
149
+ Returns:
150
+ Normalized sandbox policy as string or dict.
151
+ """
152
+ if policy is None:
153
+ return SandboxPolicy.DANGER_FULL_ACCESS
154
+
155
+ # If it's a dict, normalize the type field
156
+ if isinstance(policy, dict):
157
+ policy_value = policy.copy()
158
+ type_value = policy_value.get("type")
159
+ if isinstance(type_value, str):
160
+ policy_value["type"] = normalize_sandbox_policy_type(type_value)
161
+ return policy_value
162
+
163
+ # If it's a string, wrap in dict structure
164
+ if isinstance(policy, str):
165
+ normalized_type = normalize_sandbox_policy_type(policy)
166
+ return {"type": normalized_type}
167
+
168
+ # For other types, convert to string and wrap
169
+ return {"type": SandboxPolicy.DANGER_FULL_ACCESS}
170
+
171
+
172
+ def normalize_sandbox_policy_type(raw: str) -> str:
173
+ """Normalize sandbox policy type string to canonical value.
174
+
175
+ Args:
176
+ raw: Sandbox policy type string (case-insensitive).
177
+
178
+ Returns:
179
+ Canonical sandbox policy type.
180
+ """
181
+ if not raw:
182
+ return SandboxPolicy.DANGER_FULL_ACCESS
183
+
184
+ # Normalize case and remove special characters
185
+ import re
186
+
187
+ cleaned = re.sub(r"[^a-zA-Z0-9]+", "", raw.strip())
188
+ if not cleaned:
189
+ return SandboxPolicy.DANGER_FULL_ACCESS
190
+
191
+ canonical = _SANDBOX_POLICY_CANONICAL.get(cleaned.lower())
192
+ return canonical or raw.strip()
193
+
194
+
195
+ _SANDBOX_POLICY_CANONICAL = {
196
+ "dangerfullaccess": SandboxPolicy.DANGER_FULL_ACCESS,
197
+ "readonly": SandboxPolicy.READ_ONLY,
198
+ "workspacewrite": SandboxPolicy.WORKSPACE_WRITE,
199
+ "externalsandbox": SandboxPolicy.EXTERNAL_SANDBOX,
200
+ }
201
+
202
+
203
+ # ========================================================================
204
+ # Mapping Functions
205
+ # ========================================================================
206
+
207
+
208
+ def map_approval_to_permission(
209
+ approval_policy: Optional[str], *, default: str = PermissionPolicy.ALLOW
210
+ ) -> str:
211
+ """Map approval policy to OpenCode permission policy.
212
+
213
+ This maps Codex-style approval policies to OpenCode-style permission policies.
214
+
215
+ Args:
216
+ approval_policy: Codex approval policy.
217
+ default: Default permission if policy is None or unrecognized.
218
+
219
+ Returns:
220
+ OpenCode permission policy (allow/deny/ask).
221
+ """
222
+ if approval_policy is None:
223
+ return default
224
+
225
+ try:
226
+ normalized = normalize_approval_policy(approval_policy)
227
+ except ValueError:
228
+ # Invalid policy, return default
229
+ return default
230
+
231
+ # Direct matches
232
+ if normalized == ApprovalPolicy.NEVER:
233
+ return PermissionPolicy.ALLOW
234
+ if normalized == ApprovalPolicy.ON_FAILURE:
235
+ return PermissionPolicy.ASK
236
+ if normalized == ApprovalPolicy.ON_REQUEST:
237
+ return PermissionPolicy.ASK
238
+ if normalized == ApprovalPolicy.UNTRUSTED:
239
+ return PermissionPolicy.ASK
240
+
241
+ return default
242
+
243
+
244
+ def build_codex_sandbox_policy(
245
+ sandbox_mode: Optional[str],
246
+ *,
247
+ repo_root: Optional[Any] = None,
248
+ network_access: bool = False,
249
+ ) -> Union[str, dict[str, Any]]:
250
+ """Build Codex sandbox policy from mode string.
251
+
252
+ Args:
253
+ sandbox_mode: Sandbox mode string.
254
+ repo_root: Repository root path (for workspaceWrite policy).
255
+ network_access: Whether to allow network access (for workspaceWrite).
256
+
257
+ Returns:
258
+ Sandbox policy string or dict.
259
+ """
260
+ if not sandbox_mode:
261
+ return SandboxPolicy.DANGER_FULL_ACCESS
262
+
263
+ normalized_mode = normalize_sandbox_policy_type(sandbox_mode)
264
+
265
+ # workspaceWrite requires dict structure with writableRoots and networkAccess
266
+ if normalized_mode == SandboxPolicy.WORKSPACE_WRITE and repo_root is not None:
267
+ return {
268
+ "type": SandboxPolicy.WORKSPACE_WRITE,
269
+ "writableRoots": [str(repo_root)],
270
+ "networkAccess": network_access,
271
+ }
272
+
273
+ # Other modes can be simple strings
274
+ return normalized_mode
275
+
276
+
277
+ # ========================================================================
278
+ # Exports
279
+ # ========================================================================
280
+
281
+ __all__ = [
282
+ "ApprovalPolicy",
283
+ "SandboxPolicy",
284
+ "PermissionPolicy",
285
+ "SandboxPolicyConfig",
286
+ "PolicyMapping",
287
+ "normalize_approval_policy",
288
+ "normalize_sandbox_policy",
289
+ "normalize_sandbox_policy_type",
290
+ "map_approval_to_permission",
291
+ "build_codex_sandbox_policy",
292
+ ]
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, cast
4
+
5
+ from ..core.app_server_events import AppServerEventBuffer
6
+ from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
7
+ from .codex.harness import CodexHarness
8
+ from .opencode.harness import OpenCodeHarness
9
+ from .opencode.supervisor import OpenCodeSupervisor
10
+ from .orchestrator import AgentOrchestrator, CodexOrchestrator, OpenCodeOrchestrator
11
+ from .registry import get_agent_descriptor
12
+
13
+
14
+ def create_orchestrator(
15
+ agent_id: str,
16
+ codex_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
17
+ codex_events: Optional[AppServerEventBuffer] = None,
18
+ opencode_supervisor: Optional[OpenCodeSupervisor] = None,
19
+ ) -> AgentOrchestrator:
20
+ descriptor = get_agent_descriptor(agent_id)
21
+ if descriptor is None:
22
+ raise ValueError(f"Unknown agent: {agent_id}")
23
+
24
+ class _AppContext:
25
+ def __init__(
26
+ self,
27
+ app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
28
+ app_server_events: Optional[AppServerEventBuffer] = None,
29
+ opencode_supervisor: Optional[OpenCodeSupervisor] = None,
30
+ ):
31
+ self.app_server_supervisor = app_server_supervisor
32
+ self.app_server_events = app_server_events
33
+ self.opencode_supervisor = opencode_supervisor
34
+
35
+ app_ctx = _AppContext(codex_supervisor, codex_events, opencode_supervisor)
36
+ harness = descriptor.make_harness(app_ctx)
37
+
38
+ if agent_id == "codex":
39
+ if not isinstance(harness, CodexHarness):
40
+ raise RuntimeError(f"Expected CodexHarness but got {type(harness)}")
41
+ return CodexOrchestrator(harness, cast(AppServerEventBuffer, codex_events))
42
+ elif agent_id == "opencode":
43
+ if not isinstance(harness, OpenCodeHarness):
44
+ raise RuntimeError(f"Expected OpenCodeHarness but got {type(harness)}")
45
+ return OpenCodeOrchestrator(harness)
46
+ else:
47
+ raise RuntimeError(f"No orchestrator implementation for agent: {agent_id}")
48
+
49
+
50
+ __all__ = [
51
+ "create_orchestrator",
52
+ ]
@@ -3,12 +3,16 @@
3
3
  from .client import OpenCodeClient
4
4
  from .events import SSEEvent, parse_sse_lines
5
5
  from .harness import OpenCodeHarness
6
+ from .run_prompt import OpenCodeRunConfig, OpenCodeRunResult, run_opencode_prompt
6
7
  from .supervisor import OpenCodeSupervisor
7
8
 
8
9
  __all__ = [
9
10
  "OpenCodeClient",
10
11
  "OpenCodeHarness",
12
+ "OpenCodeRunConfig",
13
+ "OpenCodeRunResult",
11
14
  "OpenCodeSupervisor",
12
15
  "SSEEvent",
13
16
  "parse_sse_lines",
17
+ "run_opencode_prompt",
14
18
  ]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ async def ensure_agent_config(
12
+ workspace_root: Path,
13
+ agent_id: str,
14
+ model: Optional[str],
15
+ title: Optional[str] = None,
16
+ description: Optional[str] = None,
17
+ ) -> None:
18
+ """Ensure .opencode/agent/<agent_id>.md exists with frontmatter config.
19
+
20
+ Args:
21
+ workspace_root: Path to the workspace root
22
+ agent_id: Agent ID (e.g., "subagent")
23
+ model: Model ID in format "providerID/modelID" (e.g., "zai-coding-plan/glm-4.7-flashx")
24
+ title: Optional title for the agent
25
+ description: Optional description for the agent
26
+ """
27
+ if model is None:
28
+ logger.debug(f"Skipping agent config for {agent_id}: no model configured")
29
+ return
30
+
31
+ agent_dir = workspace_root / ".opencode" / "agent"
32
+ agent_file = agent_dir / f"{agent_id}.md"
33
+
34
+ # Check if file already exists and has the correct model
35
+ if agent_file.exists():
36
+ existing_content = agent_file.read_text(encoding="utf-8")
37
+ existing_model = _extract_model_from_frontmatter(existing_content)
38
+ if existing_model == model:
39
+ logger.debug(f"Agent config already exists for {agent_id}: {agent_file}")
40
+ return
41
+
42
+ # Create agent directory if needed
43
+ await asyncio.to_thread(agent_dir.mkdir, parents=True, exist_ok=True)
44
+
45
+ # Build agent markdown with frontmatter
46
+ content = _build_agent_md(
47
+ agent_id=agent_id,
48
+ model=model,
49
+ title=title or agent_id,
50
+ description=description or f"Subagent for {agent_id} tasks",
51
+ )
52
+
53
+ # Write atomically
54
+ await asyncio.to_thread(agent_file.write_text, content, encoding="utf-8")
55
+ logger.info(f"Created agent config: {agent_file} with model {model}")
56
+
57
+
58
+ def _build_agent_md(
59
+ agent_id: str,
60
+ model: str,
61
+ title: str,
62
+ description: str,
63
+ ) -> str:
64
+ """Generate markdown with YAML frontmatter.
65
+
66
+ Frontmatter format per OpenCode config schema:
67
+ ---
68
+ agent: <agent_id>
69
+ title: "<title>"
70
+ description: "<description>"
71
+ model: <providerID>/<modelID>
72
+ ---
73
+
74
+ <Optional agent instructions go here>
75
+ """
76
+ return f"""---
77
+ agent: {agent_id}
78
+ title: "{title}"
79
+ description: "{description}"
80
+ model: {model}
81
+ ---
82
+ """
83
+
84
+
85
+ def _extract_model_from_frontmatter(content: str) -> Optional[str]:
86
+ """Extract model value from YAML frontmatter.
87
+
88
+ Returns None if frontmatter or model field is not found.
89
+ """
90
+ lines = content.splitlines()
91
+ if not lines or not lines[0].startswith("---"):
92
+ return None
93
+
94
+ for _i, line in enumerate(lines[1:], start=1):
95
+ if line.startswith("---"):
96
+ break
97
+ if line.startswith("model:"):
98
+ model = line.split(":", 1)[1].strip()
99
+ return model if model else None
100
+
101
+ return None
102
+
103
+
104
+ __all__ = ["ensure_agent_config"]