misterdev 0.2.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.
Files changed (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. misterdev-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,152 @@
1
+ import time # noqa: F401 — kept so tests can patch `client.time.sleep` on this package
2
+ from typing import Optional
3
+
4
+ from misterdev.config import get_setting
5
+ from misterdev.logging_setup import setup_logger
6
+
7
+ from .base import BaseLLMClient
8
+ from .edits import APPLY_EDITS_TOOL, _edits_to_markdown, code_gen_abort_check
9
+ from .embeddings import (
10
+ LocalEmbeddingClient,
11
+ OpenRouterEmbeddingClient,
12
+ _create_local_embedding_client,
13
+ _create_openrouter_embedding_client,
14
+ create_embedding_client,
15
+ )
16
+ from .errors import (
17
+ RETRYABLE_ERROR_MARKERS,
18
+ RETRYABLE_EXCEPTION_NAMES,
19
+ RETRYABLE_STATUS_CODES,
20
+ BudgetExceededError,
21
+ LLMCallError,
22
+ _api_error,
23
+ _error_status_code,
24
+ _is_retryable_error,
25
+ )
26
+ from .providers import (
27
+ CACHE_BREAKPOINT,
28
+ AnthropicLLMClient,
29
+ OpenRouterLLMClient,
30
+ _deny_unless_training_allowed,
31
+ _openrouter_sdk,
32
+ )
33
+ from .response import LLMResponse, LLMUsage
34
+
35
+ logger = setup_logger(__name__)
36
+
37
+
38
+ class FailoverLLMClient(BaseLLMClient):
39
+ """Wraps a primary client plus ordered fallbacks for provider outages.
40
+
41
+ On a retryable error from one provider it advances to the next; a
42
+ non-retryable error (bad request) propagates immediately. Usage from
43
+ whichever provider served the request is tracked on this wrapper.
44
+ """
45
+
46
+ def __init__(self, config: dict):
47
+ super().__init__(config)
48
+ self.primary = _create_single_client(config)
49
+ self.failover_clients: list[BaseLLMClient] = []
50
+ for fc in get_setting(config, "llm", "failover"):
51
+ try:
52
+ merged = {**config, "llm": {**config["llm"], **fc, "failover": []}}
53
+ self.failover_clients.append(_create_single_client(merged))
54
+ except (ValueError, ImportError) as e:
55
+ logger.warning(f"Failover provider unavailable, skipping: {e}")
56
+
57
+ @property
58
+ def model(self) -> str:
59
+ return self.primary.model
60
+
61
+ def with_model(self, model: str):
62
+ # Route the primary path; fallbacks keep their configured models.
63
+ return self.primary.with_model(model)
64
+
65
+ def _call(self, prompt: str, system_prompt: str) -> LLMResponse:
66
+ clients = [self.primary] + self.failover_clients
67
+ last_error: Optional[LLMCallError] = None
68
+ for i, client in enumerate(clients):
69
+ try:
70
+ response = client._call(prompt, system_prompt)
71
+ if i > 0:
72
+ logger.info(
73
+ f"Failover successful via {client.__class__.__name__} ({client.model})."
74
+ )
75
+ return response
76
+ except LLMCallError as e:
77
+ last_error = e
78
+ if not e.retryable:
79
+ raise
80
+ logger.warning(
81
+ f"Provider {client.__class__.__name__} failed ({e}); trying next provider..."
82
+ )
83
+ raise last_error or LLMCallError("All LLM providers failed", retryable=False)
84
+
85
+ def _call_stream(self, prompt: str, system_prompt: str):
86
+ clients = [self.primary] + self.failover_clients
87
+ last_error: Optional[LLMCallError] = None
88
+ for i, client in enumerate(clients):
89
+ try:
90
+ usage = yield from client._call_stream(prompt, system_prompt)
91
+ if i > 0:
92
+ logger.info(
93
+ f"Failover stream via {client.__class__.__name__} ({client.model})."
94
+ )
95
+ return usage
96
+ except LLMCallError as e:
97
+ last_error = e
98
+ if not e.retryable:
99
+ raise
100
+ logger.warning(
101
+ f"Provider {client.__class__.__name__} stream failed ({e}); "
102
+ "trying next provider..."
103
+ )
104
+ raise last_error or LLMCallError("All LLM providers failed", retryable=False)
105
+
106
+
107
+ def _create_single_client(config: dict) -> BaseLLMClient:
108
+ provider = get_setting(config, "llm", "provider")
109
+ if provider == "openrouter":
110
+ return OpenRouterLLMClient(config)
111
+ elif provider == "anthropic":
112
+ return AnthropicLLMClient(config)
113
+ else:
114
+ raise ValueError(f"Unsupported LLM provider: {provider}")
115
+
116
+
117
+ def create_llm_client(config: dict) -> BaseLLMClient:
118
+ """Factory: returns a failover-wrapped client when failover config exists."""
119
+ if get_setting(config, "llm", "failover"):
120
+ return FailoverLLMClient(config)
121
+ return _create_single_client(config)
122
+
123
+
124
+ __all__ = [
125
+ "APPLY_EDITS_TOOL",
126
+ "CACHE_BREAKPOINT",
127
+ "AnthropicLLMClient",
128
+ "BaseLLMClient",
129
+ "BudgetExceededError",
130
+ "FailoverLLMClient",
131
+ "LLMCallError",
132
+ "LLMResponse",
133
+ "LLMUsage",
134
+ "LocalEmbeddingClient",
135
+ "OpenRouterEmbeddingClient",
136
+ "OpenRouterLLMClient",
137
+ "RETRYABLE_ERROR_MARKERS",
138
+ "RETRYABLE_EXCEPTION_NAMES",
139
+ "RETRYABLE_STATUS_CODES",
140
+ "_api_error",
141
+ "_create_local_embedding_client",
142
+ "_create_openrouter_embedding_client",
143
+ "_create_single_client",
144
+ "_deny_unless_training_allowed",
145
+ "_edits_to_markdown",
146
+ "_error_status_code",
147
+ "_is_retryable_error",
148
+ "_openrouter_sdk",
149
+ "code_gen_abort_check",
150
+ "create_embedding_client",
151
+ "create_llm_client",
152
+ ]
@@ -0,0 +1,382 @@
1
+ import threading
2
+ import time
3
+ from abc import ABC, abstractmethod
4
+ from contextlib import contextmanager
5
+ from typing import Dict, Optional, Tuple
6
+
7
+ from misterdev.config import get_setting
8
+ from misterdev.logging_setup import setup_logger
9
+
10
+ from .errors import BudgetExceededError, LLMCallError
11
+ from .response import LLMResponse, LLMUsage
12
+
13
+ logger = setup_logger(__name__)
14
+
15
+
16
+ class BaseLLMClient(ABC):
17
+ """Abstract LLM client with token tracking and budget enforcement."""
18
+
19
+ # Fraction of budget-remaining a single task may spend when its per-task
20
+ # cap is set to "auto".
21
+ AUTO_TASK_CAP_FRACTION = 0.5
22
+
23
+ def __init__(self, config: dict):
24
+ self.config = config
25
+ self.cumulative_usage = LLMUsage()
26
+ # Guards the usage accumulators: one client instance is hit by several
27
+ # worker threads at once (parallel analyzers, executor waves), so the
28
+ # read-modify-write of cost/token counters must be serialized or
29
+ # concurrent calls lose updates and under-count spend.
30
+ self._usage_lock = threading.Lock()
31
+ # Per-task ROUTING/ATTRIBUTION state (active model, current task, reasoning
32
+ # effort, edit-tool mode) is thread-local: parallel executor workers share
33
+ # ONE client instance (see agent._WorktreeProjectView), so a plain instance
34
+ # attribute would let one worker's with_model()/track_task() clobber
35
+ # another's mid-call — issuing a request to the wrong model or attributing
36
+ # its cost to the wrong task. The shared accounting below stays shared.
37
+ self._tls = threading.local()
38
+ self._default_model: Optional[str] = None
39
+ self.cost_by_task: Dict[str, float] = {}
40
+ self._budget = get_setting(config, "build", "budget")
41
+ # Optional per-task cost ceiling. None disables it; a number is an
42
+ # absolute cap; "auto" makes it a fraction of the budget remaining when
43
+ # the task starts (snapshotted in track_task), so it shrinks as the run
44
+ # spends and no single task can drain the global budget.
45
+ self._max_cost_per_task = get_setting(
46
+ config, "orchestrator", "max_cost_per_task"
47
+ )
48
+ self._task_caps: Dict[str, Optional[float]] = {}
49
+
50
+ @property
51
+ def model(self) -> Optional[str]:
52
+ """Active model: a thread-local ``with_model`` override, else the
53
+ configured default (set once at construction, shared across threads).
54
+
55
+ Tolerates a partially-constructed instance (``_tls``/``_default_model``
56
+ absent) so unit tests that build a client via ``__new__`` to exercise a
57
+ pure method still read ``model``."""
58
+ override = getattr(getattr(self, "_tls", None), "model", None)
59
+ return override or getattr(self, "_default_model", None)
60
+
61
+ @model.setter
62
+ def model(self, value: Optional[str]) -> None:
63
+ # Construction sets the shared default; per-call overrides go through
64
+ # with_model() (thread-local), never this setter.
65
+ self._default_model = value
66
+
67
+ @property
68
+ def _current_task(self) -> Optional[str]:
69
+ return getattr(self._tls, "current_task", None)
70
+
71
+ @_current_task.setter
72
+ def _current_task(self, value: Optional[str]) -> None:
73
+ self._tls.current_task = value
74
+
75
+ @property
76
+ def _reasoning_effort(self) -> Optional[str]:
77
+ return getattr(self._tls, "reasoning_effort", None)
78
+
79
+ @_reasoning_effort.setter
80
+ def _reasoning_effort(self, value: Optional[str]) -> None:
81
+ self._tls.reasoning_effort = value
82
+
83
+ @property
84
+ def _edit_tool_mode(self) -> bool:
85
+ return getattr(self._tls, "edit_tool_mode", False)
86
+
87
+ @_edit_tool_mode.setter
88
+ def _edit_tool_mode(self, value: bool) -> None:
89
+ self._tls.edit_tool_mode = value
90
+
91
+ def generate(self, prompt: str, system_prompt: str = "") -> LLMResponse:
92
+ """Generate a response with retry and budget enforcement.
93
+
94
+ This is the primary public interface. Subclasses implement _call().
95
+ """
96
+ self._enforce_budget()
97
+
98
+ # Free models (`:free`) are frequently rate-limited upstream and slow to
99
+ # even return the 429. Since routed calls fall back to the reliable paid
100
+ # model on failure, a free model should FAIL FAST (one shot) rather than
101
+ # burn ~minutes on slow retries before that fallback kicks in. Paid models
102
+ # keep full retry resilience for genuine transient errors.
103
+ is_free = ":free" in (getattr(self, "model", "") or "")
104
+ max_retries = 1 if is_free else 3
105
+ base_delay = 1.0
106
+
107
+ last_error = None
108
+ for attempt in range(max_retries):
109
+ try:
110
+ response = self._call(prompt, system_prompt)
111
+ self._track_usage(response.usage)
112
+ return response
113
+ except LLMCallError as e:
114
+ last_error = e
115
+ if not e.retryable or attempt == max_retries - 1:
116
+ raise
117
+ delay = base_delay * (2**attempt)
118
+ logger.warning(
119
+ f"LLM call failed (attempt {attempt + 1}/{max_retries}), "
120
+ f"retrying in {delay:.1f}s: {e}"
121
+ )
122
+ time.sleep(delay)
123
+
124
+ raise last_error
125
+
126
+ def generate_code(self, prompt: str, system_prompt: str = "") -> str:
127
+ """Convenience wrapper returning just the text content.
128
+
129
+ Maintains backward compatibility with existing callers.
130
+ """
131
+ return self.generate(prompt, system_prompt).content
132
+
133
+ def generate_edits(self, prompt: str, system_prompt: str = "") -> LLMResponse:
134
+ """Generate file edits, preferring a structured tool call when supported.
135
+
136
+ The base implementation just calls :meth:`generate` (markdown edits);
137
+ clients that support function-calling override to force the apply_edits
138
+ tool and return content rendered into the canonical fence format.
139
+ """
140
+ return self.generate(prompt, system_prompt)
141
+
142
+ def chat_multimodal(
143
+ self, prompt: str, image_b64: str, model: Optional[str] = None
144
+ ) -> str:
145
+ """Send a text+image message and return the model's text reply.
146
+
147
+ First-class multimodal entry point used by the vision gate. The base
148
+ raises so providers without a multimodal path degrade cleanly (the
149
+ vision gate then falls back / SKIPs rather than crashing).
150
+ """
151
+ raise NotImplementedError("multimodal chat not supported by this client")
152
+
153
+ def health_check(self) -> Tuple[bool, str]:
154
+ """Verify the configured model actually resolves before a real run.
155
+
156
+ A retired/misrouted model id (e.g. an OpenRouter 404) otherwise only
157
+ surfaces on the first analysis call, after preflight has already
158
+ spent setup time. This makes one minimal request and reports failure
159
+ with the model id so the build can abort early with a clear message.
160
+ """
161
+ model = getattr(self, "model", "<unknown>")
162
+ prior = self.cumulative_usage.estimated_cost
163
+ try:
164
+ self.generate("ping", "Reply with the single word OK.")
165
+ return True, model
166
+ except Exception as e:
167
+ return False, f"model {model!r} unavailable: {e}"
168
+ finally:
169
+ self.cumulative_usage.estimated_cost = prior
170
+
171
+ @contextmanager
172
+ def with_model(self, model: str):
173
+ """Temporarily override the active model for per-task routing.
174
+
175
+ The override is thread-local (a shared client is used by parallel
176
+ workers), so it must set/restore ``_tls.model`` directly rather than the
177
+ ``model`` setter, which writes the shared default.
178
+ """
179
+ had_override = hasattr(self._tls, "model")
180
+ previous = getattr(self._tls, "model", None)
181
+ self._tls.model = model
182
+ try:
183
+ yield
184
+ finally:
185
+ if had_override:
186
+ self._tls.model = previous
187
+ else:
188
+ try:
189
+ del self._tls.model
190
+ except AttributeError:
191
+ pass
192
+
193
+ @contextmanager
194
+ def with_reasoning_effort(self, effort: Optional[str]):
195
+ """Temporarily request a reasoning effort for the enclosed call(s).
196
+
197
+ Honored only by clients/models that support a reasoning budget; ignored
198
+ elsewhere. ``None`` is a no-op.
199
+ """
200
+ original = getattr(self, "_reasoning_effort", None)
201
+ self._reasoning_effort = effort
202
+ try:
203
+ yield
204
+ finally:
205
+ self._reasoning_effort = original
206
+
207
+ def _enforce_budget(self) -> None:
208
+ """Raise BudgetExceededError if the global or per-task cap is spent.
209
+
210
+ Shared by generate() and generate_stream() so both paths honor the
211
+ same budget gates before issuing a call.
212
+ """
213
+ # Read the cumulative cost under the same lock that _track_usage writes
214
+ # it, so a parallel worker mid-update can't be observed as a torn/stale
215
+ # value that lets a call slip past the ceiling.
216
+ with self._usage_lock:
217
+ spent = self.cumulative_usage.estimated_cost
218
+ if spent >= self._budget:
219
+ raise BudgetExceededError(
220
+ f"Budget of ${self._budget:.2f} exceeded (spent ${spent:.2f})"
221
+ )
222
+ if self.task_cost_exceeded(getattr(self, "_current_task", None)):
223
+ task_id = getattr(self, "_current_task", None)
224
+ cap = self.effective_task_cap(task_id)
225
+ raise BudgetExceededError(
226
+ f"Per-task budget of ${cap:.2f} exceeded "
227
+ f"for task {task_id!r} "
228
+ f"(spent ${self.task_cost(task_id):.2f})"
229
+ )
230
+
231
+ @abstractmethod
232
+ def _call(self, prompt: str, system_prompt: str) -> LLMResponse:
233
+ """Execute a single LLM API call. Subclasses implement this."""
234
+
235
+ def generate_stream(
236
+ self, prompt: str, system_prompt: str = "", abort_check=None
237
+ ) -> LLMResponse:
238
+ """Stream a response, aborting early if abort_check flags the output.
239
+
240
+ Returns finish_reason="aborted" with the partial content when the check
241
+ trips, so a caller can retry with a stricter prompt instead of waiting
242
+ for a full bad response. Usage is captured from the provider stream when
243
+ available (and estimated otherwise) so streaming honors the same budget
244
+ accounting as generate().
245
+ """
246
+ self._enforce_budget()
247
+
248
+ chunks: list[str] = []
249
+ usage: Optional[LLMUsage] = None
250
+ finish_reason = "stop"
251
+ stream = self._call_stream(prompt, system_prompt)
252
+ try:
253
+ while True:
254
+ chunks.append(next(stream))
255
+ if abort_check is not None and abort_check("".join(chunks)):
256
+ logger.warning("Aborting LLM stream: bad output pattern detected")
257
+ stream.close()
258
+ finish_reason = "aborted"
259
+ break
260
+ except StopIteration as stop:
261
+ usage = stop.value
262
+
263
+ content = "".join(chunks)
264
+ if usage is None:
265
+ usage = self._estimate_usage(prompt, system_prompt, content)
266
+ self._track_usage(usage)
267
+ return LLMResponse(
268
+ content=content,
269
+ usage=usage,
270
+ model=getattr(self, "model", ""),
271
+ finish_reason=finish_reason,
272
+ )
273
+
274
+ def _call_stream(self, prompt: str, system_prompt: str):
275
+ raise NotImplementedError("streaming not supported by this client")
276
+ yield # pragma: no cover - marks this a generator for callers
277
+
278
+ def _estimate_usage(
279
+ self, prompt: str, system_prompt: str, content: str
280
+ ) -> LLMUsage:
281
+ """Approximate usage when a stream yields no API usage data.
282
+
283
+ Used on early abort (the stream is abandoned before its usage chunk)
284
+ or with providers that omit usage. Tokens are estimated at ~4 chars
285
+ each; cost defers to the provider's _estimate_cost.
286
+ """
287
+ prompt_tokens = (len(prompt) + len(system_prompt)) // 4
288
+ completion_tokens = len(content) // 4
289
+ return LLMUsage(
290
+ prompt_tokens=prompt_tokens,
291
+ completion_tokens=completion_tokens,
292
+ total_tokens=prompt_tokens + completion_tokens,
293
+ estimated_cost=self._estimate_cost(prompt_tokens, completion_tokens),
294
+ )
295
+
296
+ def _estimate_cost(
297
+ self, prompt_tokens: int, completion_tokens: int, *args
298
+ ) -> float:
299
+ """Dollar cost for a token count. Overridden per provider."""
300
+ return 0.0
301
+
302
+ @contextmanager
303
+ def track_task(self, task_id: str):
304
+ """Attribute LLM cost/calls made in this block to a task id."""
305
+ previous = getattr(self, "_current_task", None)
306
+ self._current_task = task_id
307
+ # Snapshot the per-task cost cap on first entry so an "auto" cap is
308
+ # fixed to the budget available when the task started, not recomputed
309
+ # (and shrinking) on every call as the task spends.
310
+ if task_id is not None and task_id not in self._task_caps:
311
+ self._task_caps[task_id] = self._resolve_task_cap()
312
+ try:
313
+ yield
314
+ finally:
315
+ self._current_task = previous
316
+
317
+ def _resolve_task_cap(self) -> Optional[float]:
318
+ """Resolve the configured per-task cap to a dollar figure (or None)."""
319
+ raw = self._max_cost_per_task
320
+ if isinstance(raw, bool) or raw is None:
321
+ return None
322
+ if isinstance(raw, (int, float)):
323
+ return float(raw)
324
+ if isinstance(raw, str) and raw.strip().lower() == "auto":
325
+ return self.budget_remaining * self.AUTO_TASK_CAP_FRACTION
326
+ return None
327
+
328
+ def effective_task_cap(self, task_id: Optional[str]) -> Optional[float]:
329
+ """The dollar cap for a task, or None when uncapped.
330
+
331
+ Prefers the value snapshotted by ``track_task``; falls back to a live
332
+ resolve so the cap is meaningful even when queried before the task
333
+ block is entered.
334
+ """
335
+ if task_id is None:
336
+ return None
337
+ if task_id in self._task_caps:
338
+ return self._task_caps[task_id]
339
+ return self._resolve_task_cap()
340
+
341
+ def _track_usage(self, usage: LLMUsage) -> None:
342
+ bucket = getattr(self, "_current_task", None) or "overhead"
343
+ with self._usage_lock:
344
+ self.cumulative_usage.prompt_tokens += usage.prompt_tokens
345
+ self.cumulative_usage.completion_tokens += usage.completion_tokens
346
+ self.cumulative_usage.total_tokens += usage.total_tokens
347
+ self.cumulative_usage.estimated_cost += usage.estimated_cost
348
+ self.cumulative_usage.call_count += 1
349
+ self.cumulative_usage.cache_creation_tokens += usage.cache_creation_tokens
350
+ self.cumulative_usage.cache_read_tokens += usage.cache_read_tokens
351
+ self.cost_by_task[bucket] = (
352
+ self.cost_by_task.get(bucket, 0.0) + usage.estimated_cost
353
+ )
354
+ running = self.cumulative_usage.estimated_cost
355
+ logger.debug(
356
+ "llm call model=%s bucket=%s tokens=%d/%d cost=$%.4f cumulative=$%.4f",
357
+ getattr(self, "model", ""),
358
+ bucket,
359
+ usage.prompt_tokens,
360
+ usage.completion_tokens,
361
+ usage.estimated_cost,
362
+ running,
363
+ )
364
+
365
+ @property
366
+ def budget_remaining(self) -> float:
367
+ return max(0.0, self._budget - self.cumulative_usage.estimated_cost)
368
+
369
+ def task_cost(self, task_id: Optional[str]) -> float:
370
+ """Accumulated cost attributed to a task id (0.0 if untracked)."""
371
+ if task_id is None:
372
+ return 0.0
373
+ return getattr(self, "cost_by_task", {}).get(task_id, 0.0)
374
+
375
+ def task_cost_exceeded(self, task_id: Optional[str]) -> bool:
376
+ """True when task_id has crossed its snapshotted per-task cost cap."""
377
+ if task_id is None:
378
+ return False
379
+ cap = self.effective_task_cap(task_id)
380
+ if cap is None:
381
+ return False
382
+ return self.task_cost(task_id) >= cap
@@ -0,0 +1,70 @@
1
+ # Structured tool for edit extraction. Forcing this (when a model supports
2
+ # `tools`) replaces brittle markdown-fence parsing: the model returns
3
+ # well-formed JSON we render back into the canonical fence format the executor
4
+ # already consumes, so nothing downstream changes.
5
+ APPLY_EDITS_TOOL = {
6
+ "type": "function",
7
+ "function": {
8
+ "name": "apply_edits",
9
+ "description": (
10
+ "Write the complete final content of each file to create or modify "
11
+ "to satisfy the task."
12
+ ),
13
+ "parameters": {
14
+ "type": "object",
15
+ "properties": {
16
+ "edits": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "object",
20
+ "properties": {
21
+ "path": {
22
+ "type": "string",
23
+ "description": "Project-relative file path.",
24
+ },
25
+ "content": {
26
+ "type": "string",
27
+ "description": "Full final content of the file.",
28
+ },
29
+ },
30
+ "required": ["path", "content"],
31
+ },
32
+ }
33
+ },
34
+ "required": ["edits"],
35
+ },
36
+ },
37
+ }
38
+
39
+
40
+ def _edits_to_markdown(edits: list) -> str:
41
+ """Render structured edits into the canonical ```lang:path fence format.
42
+
43
+ The executor's parser keys on the ``path`` after the colon; the language
44
+ token is re-derived from the path downstream, so a placeholder is fine.
45
+ """
46
+ blocks = []
47
+ for edit in edits:
48
+ if not isinstance(edit, dict):
49
+ continue
50
+ path = edit.get("path")
51
+ content = edit.get("content", "")
52
+ if path:
53
+ blocks.append(f"```text:{path}\n{content}\n```")
54
+ return "\n\n".join(blocks)
55
+
56
+
57
+ def code_gen_abort_check(accumulated: str) -> bool:
58
+ """Heuristic: True when a code-gen stream is clearly going wrong.
59
+
60
+ Trips when a lot of text arrives with no code fence or file marker, or when
61
+ the model opens with conversational filler instead of code.
62
+ """
63
+ if (
64
+ len(accumulated) > 2000
65
+ and "```" not in accumulated
66
+ and "# File:" not in accumulated
67
+ ):
68
+ return True
69
+ head = accumulated[:200]
70
+ return ("I'll help you" in head) or ("Sure, here" in head)