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,368 @@
1
+ """Optional MCP (Model Context Protocol) tool-host substrate.
2
+
3
+ MCP servers expose tools the orchestrator could call (search a wiki, query a
4
+ database, run a domain-specific check) over a standard protocol. This module is
5
+ the *substrate*: it connects to the servers named in ``mcp.servers``, discovers
6
+ their tools, and can invoke one — nothing more. It does NOT turn the executor
7
+ into an autonomous tool-calling agent; the only integration is awareness
8
+ injection (the executor is told which tools exist). The agentic loop that would
9
+ let the model actually decide to call a tool mid-build is deliberately left as a
10
+ documented seam (see ``MCPManager.call_tool`` and the README).
11
+
12
+ It mirrors :mod:`misterdev.core.context.lsp`: strictly opt-in (off unless
13
+ ``orchestrator.mcp_enabled`` and ``mcp.servers`` are set), best-effort, and every
14
+ operation is run in a daemon worker thread with a hard timeout so a missing SDK,
15
+ a server that fails to start, or one that hangs can NEVER block or slow a build.
16
+ A server that misbehaves is simply absent from the registry (logged at debug);
17
+ nothing here ever raises into the caller.
18
+
19
+ The official ``mcp`` Python SDK is imported lazily inside the worker so the
20
+ dependency is optional: without it the registry is empty and the manager is a
21
+ no-op. stdio (subprocess), streamable-http, and sse transports are supported;
22
+ the last two connect to a remote ``url`` with optional auth headers, which is how
23
+ misterdev reaches a hosted MCP gateway (e.g. Glama) that fronts many servers.
24
+ """
25
+
26
+ from contextlib import asynccontextmanager
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from misterdev.core.execution.bounded import run_bounded
31
+ from misterdev.logging_setup import setup_logger
32
+
33
+ logger = setup_logger(__name__)
34
+
35
+ # Hard ceilings (seconds). Discovery may launch several subprocesses, so it gets
36
+ # a per-server budget; a single tool call is cheaper. Both are overridable via
37
+ # config but always bounded — there is no "wait forever" path.
38
+ _DEFAULT_CONNECT_TIMEOUT = 20.0
39
+ _DEFAULT_CALL_TIMEOUT = 30.0
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class MCPTool:
44
+ """One tool discovered from an MCP server.
45
+
46
+ ``server`` is the configured server name (the routing key for
47
+ :meth:`MCPManager.call_tool`); ``input_schema`` is the JSON Schema the
48
+ server advertises for the tool's arguments (may be empty).
49
+ """
50
+
51
+ server: str
52
+ name: str
53
+ description: str = ""
54
+ input_schema: Dict[str, Any] = field(default_factory=dict)
55
+
56
+ @property
57
+ def qualified_name(self) -> str:
58
+ """``server.tool`` — unique across servers, used in awareness text."""
59
+ return f"{self.server}.{self.name}"
60
+
61
+
62
+ def _normalize_servers(servers: Any) -> List[Dict[str, Any]]:
63
+ """Validate and normalize the ``mcp.servers`` config list.
64
+
65
+ Each entry must be a mapping with a non-empty ``name`` and ``command``;
66
+ malformed or duplicate-named entries are dropped with a warning rather than
67
+ raised, so a typo in one server never sinks the rest.
68
+ """
69
+ out: List[Dict[str, Any]] = []
70
+ if not isinstance(servers, list):
71
+ if servers:
72
+ logger.warning("mcp.servers must be a list; ignoring.")
73
+ return out
74
+ seen: set = set()
75
+ for entry in servers:
76
+ if not isinstance(entry, dict):
77
+ logger.warning(f"Ignoring non-mapping mcp.servers entry: {entry!r}")
78
+ continue
79
+ name = entry.get("name")
80
+ transport = (entry.get("transport") or "stdio").lower()
81
+ remote = transport in ("http", "streamable-http", "streamable_http", "sse")
82
+ if remote:
83
+ # A remote server (e.g. a hosted MCP gateway) is addressed by url.
84
+ if not name or not entry.get("url"):
85
+ logger.warning(
86
+ f"Ignoring remote mcp server without name/url: {entry!r}"
87
+ )
88
+ continue
89
+ elif transport == "stdio":
90
+ if not name or not entry.get("command"):
91
+ logger.warning(
92
+ f"Ignoring stdio mcp server without name/command: {entry!r}"
93
+ )
94
+ continue
95
+ else:
96
+ logger.debug(
97
+ f"MCP server '{name}' uses unsupported transport {transport!r}; skipping."
98
+ )
99
+ continue
100
+ if name in seen:
101
+ logger.warning(
102
+ f"Duplicate mcp server name '{name}'; ignoring the later one."
103
+ )
104
+ continue
105
+ seen.add(name)
106
+ out.append(entry)
107
+ return out
108
+
109
+
110
+ class MCPManager:
111
+ """Connects to configured MCP servers and exposes their tools.
112
+
113
+ Discovery (``tools``) connects to every server once, lists its tools, and
114
+ caches the merged result; a server that fails contributes nothing. A
115
+ :meth:`call_tool` connects to the one named server, invokes the tool, and
116
+ returns its textual result (or ``None`` on any failure). Connections are
117
+ per-operation and short-lived: nothing async is held across the synchronous
118
+ orchestrator, which keeps the integration trivially safe.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ servers: Any,
124
+ *,
125
+ connect_timeout: float = _DEFAULT_CONNECT_TIMEOUT,
126
+ call_timeout: float = _DEFAULT_CALL_TIMEOUT,
127
+ allow_tools: Optional[List[str]] = None,
128
+ ):
129
+ self.servers = _normalize_servers(servers)
130
+ self.connect_timeout = float(connect_timeout)
131
+ self.call_timeout = float(call_timeout)
132
+ # Optional allowlist of callable tools (``server.tool`` or bare ``tool``).
133
+ # None means allow all; an empty/populated set restricts which tools a
134
+ # remote gateway (e.g. Glama, which can front many servers) may expose to
135
+ # the model — the model can only see and call what you allow.
136
+ self.allow_tools: Optional[set] = set(allow_tools) if allow_tools else None
137
+ self._tools: Optional[List[MCPTool]] = None # lazy, cached
138
+
139
+ def _allowed(self, server: str, name: str) -> bool:
140
+ if self.allow_tools is None:
141
+ return True
142
+ return f"{server}.{name}" in self.allow_tools or name in self.allow_tools
143
+
144
+ @property
145
+ def enabled(self) -> bool:
146
+ """True when at least one usable server is configured."""
147
+ return bool(self.servers)
148
+
149
+ @property
150
+ def tools(self) -> List[MCPTool]:
151
+ """Merged tool registry across all servers (discovered once, cached).
152
+
153
+ Best-effort and bounded: each server's discovery is timeout-guarded, so
154
+ a slow or broken server is skipped rather than blocking the rest. Always
155
+ returns a list (possibly empty); never raises.
156
+ """
157
+ if self._tools is None:
158
+ self._tools = self._discover_all()
159
+ return self._tools
160
+
161
+ def _discover_all(self) -> List[MCPTool]:
162
+ tools: List[MCPTool] = []
163
+ for server in self.servers:
164
+ name = server["name"]
165
+ discovered = run_bounded(
166
+ lambda s=server: _list_tools(s),
167
+ self.connect_timeout,
168
+ default=[],
169
+ what=f"MCP tool discovery ({name})",
170
+ )
171
+ for t in discovered:
172
+ tool_name = t.get("name", "")
173
+ if not self._allowed(name, tool_name):
174
+ continue
175
+ tools.append(
176
+ MCPTool(
177
+ server=name,
178
+ name=tool_name,
179
+ description=t.get("description") or "",
180
+ input_schema=t.get("input_schema") or {},
181
+ )
182
+ )
183
+ if tools:
184
+ logger.info(
185
+ f"MCP discovered {len(tools)} tool(s) across "
186
+ f"{len({t.server for t in tools})} server(s)."
187
+ )
188
+ return tools
189
+
190
+ def call_tool(
191
+ self, server: str, name: str, arguments: Optional[Dict[str, Any]] = None
192
+ ) -> Optional[str]:
193
+ """Invoke ``name`` on ``server`` with ``arguments``; return its text.
194
+
195
+ Returns the tool's concatenated text content on success, or ``None`` on
196
+ any failure (unknown server, missing SDK, server error, tool error, or
197
+ timeout). Every call is logged. Bounded by ``call_timeout`` — a hanging
198
+ tool can never block a build.
199
+
200
+ SEAM: this is the single chokepoint a future agentic tool-calling loop
201
+ would drive. That loop is out of scope here; this method gives it a
202
+ clean, safe, audited entry point (config-gated, timeout-bounded,
203
+ never-raises) to build on without touching the build loop's internals.
204
+ """
205
+ cfg = next((s for s in self.servers if s["name"] == server), None)
206
+ if cfg is None:
207
+ logger.warning(f"MCP call_tool: no configured server named '{server}'.")
208
+ return None
209
+ if not self._allowed(server, name):
210
+ logger.warning(
211
+ f"MCP call_tool: {server}.{name} not in allow_tools; refused."
212
+ )
213
+ return None
214
+ logger.info(
215
+ f"MCP call_tool: {server}.{name} args={list((arguments or {}).keys())}"
216
+ )
217
+ return run_bounded(
218
+ lambda: _call_tool(cfg, name, arguments or {}),
219
+ self.call_timeout,
220
+ default=None,
221
+ what=f"MCP tool call ({server}.{name})",
222
+ )
223
+
224
+ def describe_tools(self, cap: int = 25) -> str:
225
+ """Render the registry as a concise text block for prompt awareness.
226
+
227
+ Empty string when there are no tools (so callers can append
228
+ unconditionally). Bounded by ``cap`` so a server exposing hundreds of
229
+ tools can't blow the prompt budget; each line is one tool.
230
+ """
231
+ tools = self.tools
232
+ if not tools:
233
+ return ""
234
+ lines: List[str] = []
235
+ for t in tools[:cap]:
236
+ desc = " ".join((t.description or "").split()) # collapse whitespace
237
+ if len(desc) > 200:
238
+ desc = desc[:197] + "..."
239
+ suffix = f": {desc}" if desc else ""
240
+ lines.append(f"- {t.qualified_name}{suffix}")
241
+ if len(tools) > cap:
242
+ lines.append(f"- ... and {len(tools) - cap} more")
243
+ return "\n".join(lines)
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Worker-thread bodies. Imported lazily so `mcp` stays an optional dependency,
248
+ # and each runs its own asyncio event loop in the daemon thread (the orchestrator
249
+ # is synchronous; no loop is shared or held across the call).
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def _server_params(server: Dict[str, Any]):
254
+ from mcp import StdioServerParameters
255
+
256
+ args = server.get("args") or []
257
+ if not isinstance(args, list):
258
+ args = [str(args)]
259
+ env = server.get("env")
260
+ return StdioServerParameters(
261
+ command=str(server["command"]),
262
+ args=[str(a) for a in args],
263
+ env=dict(env) if isinstance(env, dict) else None,
264
+ cwd=server.get("cwd"),
265
+ )
266
+
267
+
268
+ def _auth_headers(server: Dict[str, Any]) -> Optional[Dict[str, str]]:
269
+ """Auth headers for a remote server: explicit ``headers`` plus a Bearer token
270
+ read from ``api_key_env`` (so the token stays in the environment, never in
271
+ config on disk). Returns None when there is nothing to send."""
272
+ import os
273
+
274
+ headers = {str(k): str(v) for k, v in (server.get("headers") or {}).items()}
275
+ env_var = server.get("api_key_env")
276
+ if env_var:
277
+ token = os.environ.get(str(env_var))
278
+ if token:
279
+ headers.setdefault("Authorization", f"Bearer {token}")
280
+ else:
281
+ logger.warning(
282
+ f"MCP server '{server.get('name')}': api_key_env {env_var!r} is unset."
283
+ )
284
+ return headers or None
285
+
286
+
287
+ @asynccontextmanager
288
+ async def _open_session(server: Dict[str, Any]):
289
+ """Yield an initialized MCP ``ClientSession`` over the server's transport.
290
+
291
+ stdio launches a subprocess; ``http``/``streamable-http`` and ``sse`` connect
292
+ to a remote endpoint (``url``) with optional auth headers — this is what lets
293
+ misterdev reach a hosted MCP gateway (e.g. Glama) that fronts many servers.
294
+ The ``ClientSession`` handling is identical across transports.
295
+ """
296
+ from mcp import ClientSession
297
+
298
+ transport = (server.get("transport") or "stdio").lower()
299
+ if transport in ("http", "streamable-http", "streamable_http"):
300
+ from mcp.client.streamable_http import streamablehttp_client
301
+
302
+ async with streamablehttp_client(
303
+ server["url"], headers=_auth_headers(server)
304
+ ) as (read, write, _get_session_id):
305
+ async with ClientSession(read, write) as session:
306
+ await session.initialize()
307
+ yield session
308
+ elif transport == "sse":
309
+ from mcp.client.sse import sse_client
310
+
311
+ async with sse_client(server["url"], headers=_auth_headers(server)) as (
312
+ read,
313
+ write,
314
+ ):
315
+ async with ClientSession(read, write) as session:
316
+ await session.initialize()
317
+ yield session
318
+ else:
319
+ from mcp.client.stdio import stdio_client
320
+
321
+ async with stdio_client(_server_params(server)) as (read, write):
322
+ async with ClientSession(read, write) as session:
323
+ await session.initialize()
324
+ yield session
325
+
326
+
327
+ def _list_tools(server: Dict[str, Any]) -> List[Dict[str, Any]]:
328
+ import asyncio
329
+
330
+ async def _main() -> List[Dict[str, Any]]:
331
+ async with _open_session(server) as session:
332
+ result = await session.list_tools()
333
+ return [
334
+ {
335
+ "name": tool.name,
336
+ "description": tool.description or "",
337
+ "input_schema": getattr(tool, "inputSchema", None) or {},
338
+ }
339
+ for tool in result.tools
340
+ ]
341
+
342
+ return asyncio.run(_main())
343
+
344
+
345
+ def _call_tool(
346
+ server: Dict[str, Any], name: str, arguments: Dict[str, Any]
347
+ ) -> Optional[str]:
348
+ import asyncio
349
+
350
+ async def _main() -> Optional[str]:
351
+ async with _open_session(server) as session:
352
+ result = await session.call_tool(name, arguments=arguments)
353
+ if getattr(result, "isError", False):
354
+ logger.debug(f"MCP tool {name} returned an error result.")
355
+ return None
356
+ return _result_text(result)
357
+
358
+ return asyncio.run(_main())
359
+
360
+
361
+ def _result_text(result) -> Optional[str]:
362
+ """Concatenate the text content of a CallToolResult, or None if none."""
363
+ parts: List[str] = []
364
+ for block in getattr(result, "content", None) or []:
365
+ text = getattr(block, "text", None)
366
+ if text:
367
+ parts.append(text)
368
+ return "\n".join(parts) if parts else None
@@ -0,0 +1,186 @@
1
+ """Bounded, opt-in agentic tool-gathering loop over the MCP substrate.
2
+
3
+ When ``orchestrator.mcp_tool_use`` is on and an :class:`~misterdev.core.integration.mcp.MCPManager`
4
+ with discovered tools exists, this runs a *pre-edit* loop in which the model may
5
+ request MCP tool calls to gather information before the normal edit-generation
6
+ path runs. It is purely ADDITIVE: it produces a context string the executor
7
+ prepends to the task context, then proceeds unchanged. When the flag is off the
8
+ executor never calls this, so the build is byte-identical to today.
9
+
10
+ Discipline mirrors :mod:`misterdev.core.context.lsp` and
11
+ :mod:`misterdev.core.integration.mcp`: every tool call is bounded (it goes
12
+ through :meth:`MCPManager.call_tool`, which is timeout-guarded and never raises),
13
+ the loop is hard-capped by ``max_rounds``, each call is audited (logged), and any
14
+ failure (no manager, no tools, model error, unparseable request, tool error)
15
+ degrades to "gather nothing" rather than raising into the build.
16
+
17
+ Protocol (deterministic, tolerant of a model that wants no tool): each round the
18
+ model is shown the available tools and asked to reply with EITHER a single line
19
+
20
+ CALL <server>.<tool> {"arg": value, ...}
21
+
22
+ to request one tool call, OR the literal token ``NO_TOOL`` (or anything that does
23
+ not parse as a CALL line) to stop. We parse the first valid CALL line found; if
24
+ none is found the loop stops. JSON args are optional (a missing/empty/invalid
25
+ args object is treated as ``{}``, logged). The tool result is appended to the
26
+ running gathered-context block and fed back into the next round's prompt.
27
+ """
28
+
29
+ import json
30
+ import re
31
+ from typing import Callable, Dict, List, Optional, Tuple
32
+
33
+ from misterdev.core.integration.mcp import MCPManager
34
+ from misterdev.llm.responses import (
35
+ extract_balanced_span as _extract_balanced_object,
36
+ )
37
+ from misterdev.logging_setup import setup_logger
38
+
39
+ logger = setup_logger(__name__)
40
+
41
+ # "CALL server.tool" anywhere in the reply. Tolerant on purpose: searched (not
42
+ # anchored) so leading markdown (backticks, ``**``, ``> ``) and trailing prose
43
+ # don't break it, and case-insensitive with word boundaries so neither "recall"
44
+ # nor a lowercased keyword trips it. The server keeps dots (``a.b.tool`` ->
45
+ # server ``a.b``, tool ``tool``); the args object is located and balanced
46
+ # separately so it may span multiple lines.
47
+ _CALL_RE = re.compile(
48
+ r"\bCALL\b\s+([A-Za-z0-9_.\-]+)\.([A-Za-z0-9_\-]+)",
49
+ re.IGNORECASE,
50
+ )
51
+
52
+ _GATHER_HEADER = "## Information gathered via tools\n"
53
+
54
+ _INSTRUCTION = (
55
+ "You may gather information using the tools below BEFORE editing. "
56
+ "To call a tool, reply with EXACTLY one line:\n"
57
+ ' CALL <server>.<tool> {{"arg": value}}\n'
58
+ "(the JSON arguments object is optional). To gather nothing further and "
59
+ "proceed to editing, reply with NO_TOOL. Request at most one tool per reply.\n\n"
60
+ "## Available tools\n{tools}\n"
61
+ )
62
+
63
+
64
+ def _parse_call(text: str) -> Optional[Tuple[str, str, dict]]:
65
+ """Parse the first ``CALL server.tool {json}`` line; ``None`` if absent.
66
+
67
+ A present-but-malformed JSON args object degrades to ``{}`` (logged), so the
68
+ tool is still attempted with no arguments rather than the round being lost.
69
+ """
70
+ if not text:
71
+ return None
72
+ m = _CALL_RE.search(text)
73
+ if m is None:
74
+ return None
75
+ server, tool = m.group(1), m.group(2)
76
+ args: dict = {}
77
+ brace = text.find("{", m.end())
78
+ # Only treat a following object as the args if nothing but whitespace
79
+ # separates it from the call, so a stray ``{`` in later prose isn't grabbed.
80
+ if brace != -1 and text[m.end() : brace].strip() == "":
81
+ raw_args = _extract_balanced_object(text, brace)
82
+ if raw_args:
83
+ try:
84
+ parsed = json.loads(raw_args)
85
+ if isinstance(parsed, dict):
86
+ args = parsed
87
+ else:
88
+ logger.debug("MCP gather: CALL args not a JSON object; using {}.")
89
+ except (json.JSONDecodeError, ValueError):
90
+ logger.debug("MCP gather: unparseable CALL args; using {}.")
91
+ return server, tool, args
92
+
93
+
94
+ def gather_context(
95
+ manager: Optional[MCPManager],
96
+ ask: Callable[[str], Optional[str]],
97
+ *,
98
+ task_description: str = "",
99
+ max_rounds: int = 3,
100
+ tools_cap: int = 25,
101
+ local_tools: Optional[
102
+ Dict[str, Tuple[str, Callable[[dict], Optional[str]]]]
103
+ ] = None,
104
+ ) -> str:
105
+ """Run the bounded tool-gathering loop; return the gathered-context block.
106
+
107
+ ``manager`` is the MCP manager (may be ``None``). ``local_tools`` maps a
108
+ registered gather-safe tool's name to ``(description, call)`` and is exposed
109
+ to the model as ``local.<name>``, so plugin tools and MCP tools are called
110
+ through the one loop. ``ask`` takes a prompt and returns the model's reply
111
+ (``None``/empty stops). ``max_rounds`` hard-caps the iterations; < 1 disables.
112
+ Returns a context string (empty when nothing was gathered), suitable for
113
+ prepending to the task context. Never raises: any failure is logged and
114
+ degrades to whatever was gathered so far.
115
+ """
116
+ local_tools = local_tools or {}
117
+ if (manager is None and not local_tools) or max_rounds < 1:
118
+ return ""
119
+ tools = ""
120
+ if manager is not None:
121
+ try:
122
+ tools = manager.describe_tools(cap=tools_cap) or ""
123
+ except Exception as e: # discovery problem; degrade to local tools only
124
+ logger.debug(f"gather: MCP tool discovery failed: {e}")
125
+ tools = ""
126
+ if local_tools:
127
+ local_desc = "\n".join(
128
+ f"- local.{name}: {desc}" for name, (desc, _) in local_tools.items()
129
+ )
130
+ tools = f"{tools}\n{local_desc}" if tools else local_desc
131
+ if not tools:
132
+ return ""
133
+
134
+ gathered: List[str] = []
135
+ for round_idx in range(max_rounds):
136
+ prompt = _INSTRUCTION.format(tools=tools)
137
+ if task_description:
138
+ prompt += (
139
+ f"\n## Task you are gathering information for\n{task_description}\n"
140
+ )
141
+ if gathered:
142
+ prompt += "\n" + _GATHER_HEADER + "\n\n".join(gathered) + "\n"
143
+
144
+ try:
145
+ reply = ask(prompt)
146
+ except Exception as e: # a model/client failure must not sink the build
147
+ logger.debug(f"MCP gather: model call failed in round {round_idx + 1}: {e}")
148
+ break
149
+
150
+ parsed = _parse_call(reply or "")
151
+ if parsed is None:
152
+ logger.info(
153
+ f"MCP gather: model requested no tool in round {round_idx + 1}; "
154
+ "stopping."
155
+ )
156
+ break
157
+
158
+ server, tool, args = parsed
159
+ logger.info(
160
+ f"gather round {round_idx + 1}/{max_rounds}: CALL {server}.{tool} "
161
+ f"args={sorted(args.keys())}"
162
+ )
163
+ if server == "local" and tool in local_tools:
164
+ try:
165
+ result = local_tools[tool][1](args)
166
+ except Exception as e: # a plugin tool must not sink the build
167
+ logger.debug(f"gather: local tool {tool!r} error: {e}")
168
+ result = None
169
+ elif manager is not None:
170
+ result = manager.call_tool(server, tool, args)
171
+ else:
172
+ result = None
173
+ if result is None:
174
+ # Tool error / unknown server / timeout already logged by call_tool.
175
+ gathered.append(f"### {server}.{tool} -> (no result / error)")
176
+ continue
177
+ gathered.append(f"### {server}.{tool}\n{result}")
178
+
179
+ if not gathered:
180
+ return ""
181
+ return (
182
+ "\n\n"
183
+ + _GATHER_HEADER
184
+ + "These results were gathered via tools to inform the edit below.\n\n"
185
+ + "\n\n".join(gathered)
186
+ )
@@ -0,0 +1,35 @@
1
+ from pydantic import BaseModel, Field
2
+ from typing import Dict, Any, List, Optional
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ class ExecutionResult(BaseModel):
7
+ status: str # 'completed', 'failed', 'in_progress'
8
+ message: str
9
+ logs: str = ""
10
+ start_time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
11
+ end_time: Optional[datetime] = None
12
+
13
+
14
+ class Task(BaseModel):
15
+ id: str
16
+ description: str
17
+ type: str = "default"
18
+ status: str = "pending"
19
+ source_ref: Optional[str] = None # e.g., file path
20
+ project_ref: str
21
+ processor_data: Dict[str, Any] = Field(default_factory=dict)
22
+ execution_history: List[ExecutionResult] = Field(default_factory=list)
23
+ # Fields ported from /build skill task decomposition
24
+ title: str = ""
25
+ acceptance_criteria: str = ""
26
+ files_to_create: List[str] = Field(default_factory=list)
27
+ files_to_modify: List[str] = Field(default_factory=list)
28
+ dependencies: List[str] = Field(
29
+ default_factory=list
30
+ ) # task IDs that must complete first
31
+ complexity: str = "medium" # trivial, small, medium, large, architectural
32
+ category: str = "feature" # infrastructure, core, feature, fix, test, docs, integration, cleanup
33
+ context_files: List[str] = Field(
34
+ default_factory=list
35
+ ) # Files relevant for context but not modified