morphsdk 0.2.5__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 (61) hide show
  1. morphsdk/__init__.py +54 -0
  2. morphsdk/_agent/__init__.py +64 -0
  3. morphsdk/_agent/config.py +52 -0
  4. morphsdk/_agent/explore.py +276 -0
  5. morphsdk/_agent/github.py +57 -0
  6. morphsdk/_agent/helpers.py +133 -0
  7. morphsdk/_agent/parser.py +163 -0
  8. morphsdk/_agent/runner.py +524 -0
  9. morphsdk/_agent/tools.py +171 -0
  10. morphsdk/_agent/types.py +126 -0
  11. morphsdk/_base.py +309 -0
  12. morphsdk/_client.py +245 -0
  13. morphsdk/_config.py +37 -0
  14. morphsdk/_constants.py +53 -0
  15. morphsdk/_errors.py +111 -0
  16. morphsdk/_providers/__init__.py +36 -0
  17. morphsdk/_providers/_filter.py +92 -0
  18. morphsdk/_providers/base.py +94 -0
  19. morphsdk/_providers/code_storage_http.py +104 -0
  20. morphsdk/_providers/local.py +270 -0
  21. morphsdk/_providers/remote.py +161 -0
  22. morphsdk/_version.py +1 -0
  23. morphsdk/adapters/__init__.py +1 -0
  24. morphsdk/adapters/anthropic.py +360 -0
  25. morphsdk/adapters/langchain.py +120 -0
  26. morphsdk/adapters/openai.py +500 -0
  27. morphsdk/py.typed +0 -0
  28. morphsdk/resources/__init__.py +0 -0
  29. morphsdk/resources/browser.py +919 -0
  30. morphsdk/resources/compact.py +133 -0
  31. morphsdk/resources/edit.py +506 -0
  32. morphsdk/resources/explore.py +333 -0
  33. morphsdk/resources/git.py +861 -0
  34. morphsdk/resources/github.py +1214 -0
  35. morphsdk/resources/grep.py +583 -0
  36. morphsdk/resources/mobile.py +134 -0
  37. morphsdk/resources/reflex.py +414 -0
  38. morphsdk/resources/router.py +124 -0
  39. morphsdk/resources/search.py +110 -0
  40. morphsdk/tracing/__init__.py +70 -0
  41. morphsdk/tracing/_otel.py +101 -0
  42. morphsdk/tracing/core.py +249 -0
  43. morphsdk/tracing/interaction.py +284 -0
  44. morphsdk/tracing/otel.py +75 -0
  45. morphsdk/tracing/reflex.py +58 -0
  46. morphsdk/tracing/types.py +163 -0
  47. morphsdk/types/__init__.py +140 -0
  48. morphsdk/types/browser.py +118 -0
  49. morphsdk/types/compact.py +41 -0
  50. morphsdk/types/edit.py +31 -0
  51. morphsdk/types/explore.py +42 -0
  52. morphsdk/types/git.py +25 -0
  53. morphsdk/types/github.py +111 -0
  54. morphsdk/types/grep.py +41 -0
  55. morphsdk/types/mobile.py +25 -0
  56. morphsdk/types/reflex.py +137 -0
  57. morphsdk/types/router.py +21 -0
  58. morphsdk/types/search.py +33 -0
  59. morphsdk-0.2.5.dist-info/METADATA +226 -0
  60. morphsdk-0.2.5.dist-info/RECORD +61 -0
  61. morphsdk-0.2.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,163 @@
1
+ """Parsers for the string-format tool arguments the model emits.
2
+
3
+ Faithful port of ``agent/parser.ts`` plus the range helpers from
4
+ ``agent/tools/finish.ts``. These convert the loose, model-authored strings
5
+ (``"1-50,80-90"``, ``"src/a.py:1-15 src/b.py"``) into structured ranges, exactly
6
+ matching the TypeScript behaviour (including Windows drive-letter handling and
7
+ whitespace-splitting of finish files).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Awaitable
14
+ from typing import Callable
15
+
16
+ from .types import FinishFileSpec, FinishLines
17
+
18
+ _DRIVE_LETTER = re.compile(r"^[A-Za-z]:")
19
+ _WHITESPACE = re.compile(r"\s+")
20
+
21
+
22
+ def _parse_int(value: str) -> int | None:
23
+ """Mirror JS ``parseInt(v, 10)`` followed by ``Number.isFinite``: leading
24
+ integer prefix, ``None`` on failure."""
25
+ match = re.match(r"[+-]?\d+", value.strip())
26
+ return int(match.group()) if match else None
27
+
28
+
29
+ def parse_read_lines(lines_str: str) -> dict[str, object]:
30
+ """Parse a ``read`` tool ``lines`` arg into ``{start,end}`` or ``{lines:[...]}``.
31
+
32
+ Mirrors ``parseReadLines``: a single range collapses to ``start``/``end``;
33
+ multiple ranges return a ``lines`` list of ``(start, end)`` tuples.
34
+ """
35
+ ranges: list[tuple[int, int]] = []
36
+ for range_str in lines_str.split(","):
37
+ trimmed = range_str.strip()
38
+ if not trimmed:
39
+ continue
40
+ parts = [_parse_int(p) for p in trimmed.split("-")]
41
+ if len(parts) >= 2 and parts[0] is not None and parts[1] is not None:
42
+ ranges.append((parts[0], parts[1]))
43
+ elif parts and parts[0] is not None:
44
+ ranges.append((parts[0], parts[0]))
45
+ if len(ranges) == 1:
46
+ return {"start": ranges[0][0], "end": ranges[0][1]}
47
+ if len(ranges) > 1:
48
+ return {"lines": ranges}
49
+ return {}
50
+
51
+
52
+ def parse_finish_files(files_str: str) -> list[FinishFileSpec]:
53
+ """Parse a ``finish`` tool ``files`` arg into :class:`FinishFileSpec` list.
54
+
55
+ Splits on any whitespace (training uses space-separated; newlines also work),
56
+ then splits each token on the first ``:`` after any Windows drive prefix.
57
+ """
58
+ files: list[FinishFileSpec] = []
59
+ for line in _WHITESPACE.split(files_str.strip()):
60
+ trimmed = line.strip()
61
+ if not trimmed:
62
+ continue
63
+ search_from = 2 if _DRIVE_LETTER.match(trimmed) else 0
64
+ colon_idx = trimmed.find(":", search_from)
65
+ if colon_idx == -1:
66
+ files.append(FinishFileSpec(path=trimmed, lines="*"))
67
+ continue
68
+ file_path = trimmed[:colon_idx]
69
+ ranges_part = trimmed[colon_idx + 1 :]
70
+ if not ranges_part.strip() or ranges_part.strip() == "*":
71
+ files.append(FinishFileSpec(path=file_path, lines="*"))
72
+ continue
73
+ ranges: list[tuple[int, int]] = []
74
+ for range_str in ranges_part.split(","):
75
+ rt = range_str.strip()
76
+ if not rt:
77
+ continue
78
+ parts = [_parse_int(p) for p in rt.split("-")]
79
+ if len(parts) >= 2 and parts[0] is not None and parts[1] is not None:
80
+ ranges.append((parts[0], parts[1]))
81
+ elif parts and parts[0] is not None:
82
+ ranges.append((parts[0], parts[0]))
83
+ files.append(FinishFileSpec(path=file_path, lines=ranges if ranges else "*"))
84
+ return files
85
+
86
+
87
+ def extract_path_from_command(command: str) -> str:
88
+ """Extract the directory path from an ``ls``/``find`` command string.
89
+
90
+ Mirrors ``extractPathFromCommand``: drop the binary token, then take the
91
+ first non-flag, non-pipe, non-paren token, defaulting to ``"."``.
92
+ """
93
+ tokens = _WHITESPACE.split(command.strip())
94
+ path_tokens = [
95
+ t
96
+ for t in tokens[1:]
97
+ if not t.startswith("-") and not t.startswith("|") and not t.startswith("\\(")
98
+ ]
99
+ return path_tokens[0] if path_tokens else "."
100
+
101
+
102
+ def _merge_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
103
+ """Sort and coalesce overlapping/adjacent ranges (mirrors ``mergeRanges``)."""
104
+ if not ranges:
105
+ return []
106
+ sorted_ranges = sorted(ranges, key=lambda r: r[0])
107
+ merged: list[tuple[int, int]] = []
108
+ cs, ce = sorted_ranges[0]
109
+ for s, e in sorted_ranges[1:]:
110
+ if s <= ce + 1:
111
+ ce = max(ce, e)
112
+ else:
113
+ merged.append((cs, ce))
114
+ cs, ce = s, e
115
+ merged.append((cs, ce))
116
+ return merged
117
+
118
+
119
+ def _is_valid_range(rng: object) -> bool:
120
+ return (
121
+ isinstance(rng, (tuple, list))
122
+ and len(rng) >= 2
123
+ and isinstance(rng[0], int)
124
+ and isinstance(rng[1], int)
125
+ and rng[0] > 0
126
+ and rng[1] >= rng[0]
127
+ )
128
+
129
+
130
+ def _extract_valid_ranges(lines: FinishLines) -> list[tuple[int, int]] | None:
131
+ if not isinstance(lines, list):
132
+ return None
133
+ valid = [(r[0], r[1]) for r in lines if _is_valid_range(r)]
134
+ return valid if valid else None
135
+
136
+
137
+ async def read_finish_files(
138
+ files: list[FinishFileSpec],
139
+ reader: Callable[[str, int | None, int | None], Awaitable[list[str]]],
140
+ ) -> list[dict[str, object]]:
141
+ """Resolve finish files to concatenated content via *reader*.
142
+
143
+ Faithful port of ``readFinishFiles``: full files are read whole; ranged files
144
+ are merged, then each block is read and joined with the
145
+ ``// ... existing code, block starting at line N ...`` separators the TS uses.
146
+ Returns dicts with ``path`` / ``ranges`` / ``content``.
147
+ """
148
+ out: list[dict[str, object]] = []
149
+ for f in files:
150
+ valid_ranges = None if f.lines == "*" else _extract_valid_ranges(f.lines)
151
+ if f.lines == "*" or not valid_ranges:
152
+ lines = await reader(f.path, None, None)
153
+ out.append({"path": f.path, "ranges": "*", "content": "\n".join(lines)})
154
+ else:
155
+ ranges = _merge_ranges(valid_ranges)
156
+ chunks: list[str] = []
157
+ for i, (s, e) in enumerate(ranges):
158
+ if (i == 0 and s > 1) or i > 0:
159
+ chunks.append(f"// ... existing code, block starting at line {s} ...")
160
+ lines = await reader(f.path, s, e)
161
+ chunks.append("\n".join(lines))
162
+ out.append({"path": f.path, "ranges": ranges, "content": "\n".join(chunks)})
163
+ return out
@@ -0,0 +1,524 @@
1
+ """WarpGrep multi-turn agent loop (async core).
2
+
3
+ Faithful port of ``agent/runner.ts``. The loop:
4
+
5
+ 1. Builds an initial user message with the repo structure + search string.
6
+ 2. Each turn: enforces the context limit, calls the model, appends the assistant
7
+ message, executes any non-finish tool calls in parallel, appends tool results
8
+ plus a turn/budget hint.
9
+ 3. Stops on a ``finish`` call (resolving the selected files to content), when the
10
+ model stops emitting tool calls, on a model error, or at ``MAX_TURNS``.
11
+
12
+ The chat-completions request shape (endpoint, model, body, tool schemas) is
13
+ copied byte-for-byte from the TS so the paid API receives identical traffic.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import time
22
+ from collections.abc import AsyncIterator
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from morphsdk._providers.base import WarpGrepProvider
28
+ from morphsdk._version import __version__
29
+
30
+ from .config import DEFAULT_MODEL, DEFAULT_TIMEOUT_S, MAX_TURNS
31
+ from .helpers import (
32
+ build_initial_state,
33
+ calculate_context_budget,
34
+ enforce_context_limit,
35
+ format_turn_message,
36
+ )
37
+ from .parser import parse_finish_files, read_finish_files
38
+ from .tools import execute_tool
39
+ from .types import (
40
+ AgentFinish,
41
+ AgentRunResult,
42
+ ChatMessage,
43
+ ResolvedContext,
44
+ ToolCallRef,
45
+ WarpGrepExecutionMetrics,
46
+ WarpGrepStep,
47
+ WarpGrepTurnMetrics,
48
+ )
49
+
50
+ _DEFAULT_API_URL = "https://api.morphllm.com"
51
+ _RETRYABLE_STATUS = {429, 503}
52
+
53
+ # Tool definitions sent to the model (OpenAI function-calling format).
54
+ # Copied byte-for-byte from TOOL_SPECS in agent/runner.ts.
55
+ TOOL_SPECS: list[dict[str, Any]] = [
56
+ {
57
+ "type": "function",
58
+ "function": {
59
+ "name": "list_directory",
60
+ "description": "Execute ls or find commands to explore directory structure. Max 500 results. Common junk directories are excluded automatically.", # noqa: E501
61
+ "parameters": {
62
+ "type": "object",
63
+ "properties": {
64
+ "command": {
65
+ "type": "string",
66
+ "description": "Full ls or find command (e.g. ls -la src/, find . -maxdepth 2 -type f -name '*.py', find . -type d, ls -d */).", # noqa: E501
67
+ },
68
+ },
69
+ "required": ["command"],
70
+ },
71
+ },
72
+ },
73
+ {
74
+ "type": "function",
75
+ "function": {
76
+ "name": "grep_search",
77
+ "description": "Search for a regex pattern in file contents. Returns matching lines with file paths and line numbers. Case-insensitive. Respects .gitignore.", # noqa: E501
78
+ "parameters": {
79
+ "type": "object",
80
+ "properties": {
81
+ "pattern": {
82
+ "type": "string",
83
+ "description": "Regex pattern to search for in file contents (e.g. 'class\\s+\\w+Error', 'import|require|from', 'def (get|set|update)_user').", # noqa: E501
84
+ },
85
+ "path": {
86
+ "type": "string",
87
+ "description": "File or directory to search in. Defaults to current working directory.", # noqa: E501
88
+ },
89
+ "glob": {
90
+ "type": "string",
91
+ "description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx,js,jsx,py,go}', 'src/**/*.go', '!*.test.*').", # noqa: E501
92
+ },
93
+ "limit": {
94
+ "type": "integer",
95
+ "description": "Limit output to first N matching lines. Shows all matches if not specified.", # noqa: E501
96
+ },
97
+ },
98
+ "required": ["pattern"],
99
+ },
100
+ },
101
+ },
102
+ {
103
+ "type": "function",
104
+ "function": {
105
+ "name": "glob",
106
+ "description": "Find files by name/extension using glob patterns. Returns absolute paths sorted by modification time (newest first). Respects .gitignore. Max 100 results.", # noqa: E501
107
+ "parameters": {
108
+ "type": "object",
109
+ "properties": {
110
+ "pattern": {
111
+ "type": "string",
112
+ "description": "Glob pattern to match files (e.g. '*.py', 'src/**/*.js', '*.{ts,tsx}', 'test_*.py').", # noqa: E501
113
+ },
114
+ "path": {
115
+ "type": "string",
116
+ "description": "Directory to search in. Defaults to repository root.",
117
+ },
118
+ },
119
+ "required": ["pattern"],
120
+ },
121
+ },
122
+ },
123
+ {
124
+ "type": "function",
125
+ "function": {
126
+ "name": "read",
127
+ "description": "Read entire files or specific line ranges using absolute paths.",
128
+ "parameters": {
129
+ "type": "object",
130
+ "properties": {
131
+ "path": {
132
+ "type": "string",
133
+ "description": "File path to read, using absolute path (e.g. '/home/ubuntu/repo/src/main.py' or windows path).", # noqa: E501
134
+ },
135
+ "lines": {
136
+ "type": "string",
137
+ "description": "Optional line range (e.g. '1-50' or '1-20,45-80'). Omit to read entire file.", # noqa: E501
138
+ },
139
+ },
140
+ "required": ["path"],
141
+ },
142
+ },
143
+ },
144
+ {
145
+ "type": "function",
146
+ "function": {
147
+ "name": "finish",
148
+ "description": "Submit final answer with all relevant code locations. Include imports and over-include rather than miss context.", # noqa: E501
149
+ "parameters": {
150
+ "type": "object",
151
+ "properties": {
152
+ "files": {
153
+ "type": "string",
154
+ "description": "One file per line as path:lines (e.g. 'src/auth.py:1-15,25-50\\nsrc/user.py'). Omit line range to include entire file.", # noqa: E501
155
+ },
156
+ },
157
+ "required": ["files"],
158
+ },
159
+ },
160
+ },
161
+ ]
162
+
163
+
164
+ def _safe_parse_json(s: str) -> dict[str, Any]:
165
+ try:
166
+ parsed = json.loads(s)
167
+ return parsed if isinstance(parsed, dict) else {}
168
+ except (ValueError, TypeError):
169
+ return {}
170
+
171
+
172
+ def _resolve_base_url(api_url: str | None) -> str:
173
+ """Mirror TS: append ``/v1`` only when the base URL has a root path."""
174
+ base = api_url or _DEFAULT_API_URL
175
+ # Strip a trailing slash for consistent joins.
176
+ trimmed = base.rstrip("/")
177
+ # TS: parsedUrl.pathname === '/' ? `${baseUrl}/v1` : baseUrl
178
+ # i.e. a bare host (no path) gets /v1 appended; an explicit path is used as-is.
179
+ from urllib.parse import urlparse
180
+
181
+ path = urlparse(base).path
182
+ if path in ("", "/"):
183
+ return f"{trimmed}/v1"
184
+ return trimmed
185
+
186
+
187
+ async def call_model(
188
+ messages: list[ChatMessage],
189
+ model: str,
190
+ *,
191
+ api_key: str,
192
+ api_url: str | None = None,
193
+ timeout: float | None = None,
194
+ search_type: str | None = None,
195
+ max_retries: int = 3,
196
+ ) -> dict[str, Any]:
197
+ """POST one chat-completions request and return ``{content, tool_calls}``.
198
+
199
+ Request body matches the TS exactly: ``temperature=0.0``, ``max_tokens=2048``,
200
+ the warp-grep model, the full ``TOOL_SPECS`` tool list, an optional
201
+ ``search_type`` field, and the ``X-Morph-SDK-Version`` header. Retries on
202
+ 429/503 (mirroring the SDK base client), then one empty-response retry like TS.
203
+ """
204
+ base_url = _resolve_base_url(api_url)
205
+ endpoint = f"{base_url}/chat/completions"
206
+ timeout_s = timeout if timeout is not None else DEFAULT_TIMEOUT_S
207
+
208
+ body: dict[str, Any] = {
209
+ "model": model,
210
+ "temperature": 0.0,
211
+ "max_tokens": 2048,
212
+ "messages": [m.to_wire() for m in messages],
213
+ "tools": TOOL_SPECS,
214
+ }
215
+ if search_type:
216
+ body["search_type"] = search_type
217
+
218
+ headers = {
219
+ "Authorization": f"Bearer {api_key}",
220
+ "Content-Type": "application/json",
221
+ "X-Morph-SDK-Version": __version__,
222
+ "X-Morph-SDK-Lang": "python",
223
+ }
224
+
225
+ max_empty_retries = 1
226
+ async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_s)) as client:
227
+ for attempt in range(max_empty_retries + 1):
228
+ data = await _post_with_retry(client, endpoint, body, headers, max_retries)
229
+ choice = (data.get("choices") or [{}])[0]
230
+ message = choice.get("message")
231
+ if not message:
232
+ if attempt == max_empty_retries:
233
+ raise RuntimeError("Invalid response from model: no message in response")
234
+ await asyncio.sleep(0.2)
235
+ continue
236
+
237
+ tool_calls = [
238
+ ToolCallRef(
239
+ id=tc["id"],
240
+ name=tc["function"]["name"],
241
+ arguments=tc["function"]["arguments"],
242
+ )
243
+ for tc in (message.get("tool_calls") or [])
244
+ ]
245
+ content = message.get("content")
246
+ if content or tool_calls:
247
+ return {"content": content, "tool_calls": tool_calls}
248
+
249
+ if attempt == max_empty_retries:
250
+ finish_reason = choice.get("finish_reason", "unknown")
251
+ raise RuntimeError(
252
+ "Invalid response from model: no content and no tool_calls, "
253
+ f"finish_reason={finish_reason}"
254
+ )
255
+ await asyncio.sleep(0.2)
256
+
257
+ raise RuntimeError("Invalid response from model")
258
+
259
+
260
+ async def _post_with_retry(
261
+ client: httpx.AsyncClient,
262
+ endpoint: str,
263
+ body: dict[str, Any],
264
+ headers: dict[str, str],
265
+ max_retries: int,
266
+ ) -> dict[str, Any]:
267
+ """POST with 429/503 retry-with-backoff, mirroring ``_base.py`` semantics."""
268
+ delay = 1.0
269
+ last_exc: Exception | None = None
270
+ for attempt in range(max_retries + 1):
271
+ try:
272
+ res = await client.post(endpoint, json=body, headers=headers)
273
+ except httpx.TimeoutException as err:
274
+ last_exc = err
275
+ if attempt < max_retries:
276
+ await asyncio.sleep(min(delay, 30.0))
277
+ delay *= 2.0
278
+ continue
279
+ raise
280
+ if res.status_code in _RETRYABLE_STATUS and attempt < max_retries:
281
+ retry_after = res.headers.get("Retry-After")
282
+ wait = float(retry_after) if retry_after else min(delay, 30.0)
283
+ await asyncio.sleep(wait)
284
+ delay *= 2.0
285
+ continue
286
+ res.raise_for_status()
287
+ data: dict[str, Any] = res.json()
288
+ return data
289
+ if last_exc:
290
+ raise last_exc
291
+ raise RuntimeError("Max retries exceeded")
292
+
293
+
294
+ async def run_warp_grep_streaming(
295
+ *,
296
+ search_term: str,
297
+ repo_root: str,
298
+ provider: WarpGrepProvider,
299
+ api_key: str,
300
+ api_url: str | None = None,
301
+ model: str | None = None,
302
+ timeout: float | None = None,
303
+ search_type: str | None = None,
304
+ max_turns: int | None = None,
305
+ max_retries: int = 3,
306
+ ) -> AsyncIterator[WarpGrepStep | AgentRunResult]:
307
+ """Run the agent loop, yielding a :class:`WarpGrepStep` per turn and finally
308
+ a single :class:`AgentRunResult` (the last item yielded).
309
+
310
+ Python generators cannot carry a separate return value the way TS async
311
+ generators do, so the final :class:`AgentRunResult` is yielded as the last
312
+ item. Consumers distinguish steps from the result by type.
313
+
314
+ ``max_turns`` overrides the default per-run turn cap (:data:`MAX_TURNS`);
315
+ callers like the Explore subagent use it to scale search depth.
316
+ """
317
+ total_start = time.monotonic()
318
+ timeout_s = timeout if timeout is not None else DEFAULT_TIMEOUT_S
319
+ timings = WarpGrepExecutionMetrics(timeout_ms=int(timeout_s * 1000))
320
+
321
+ repo = repo_root or os.getcwd()
322
+ mdl = model or DEFAULT_MODEL
323
+ turn_cap = max_turns if max_turns is not None else MAX_TURNS
324
+ messages: list[ChatMessage] = []
325
+
326
+ initial_start = time.monotonic()
327
+ initial_state = await build_initial_state(repo, search_term, provider, search_type=search_type)
328
+ timings.initial_state_ms = int((time.monotonic() - initial_start) * 1000)
329
+ messages.append(ChatMessage(role="user", content=initial_state))
330
+
331
+ errors: list[dict[str, str]] = []
332
+ finish_meta: AgentFinish | None = None
333
+ termination_reason: str = "terminated"
334
+
335
+ for turn in range(1, turn_cap + 1):
336
+ turn_metrics = WarpGrepTurnMetrics(turn=turn)
337
+ enforce_context_limit(messages)
338
+
339
+ model_start = time.monotonic()
340
+ try:
341
+ response = await call_model(
342
+ messages,
343
+ mdl,
344
+ api_key=api_key,
345
+ api_url=api_url,
346
+ timeout=timeout_s,
347
+ search_type=search_type,
348
+ max_retries=max_retries,
349
+ )
350
+ except Exception as err: # noqa: BLE001 - surface as terminated, like TS
351
+ errors.append({"message": str(err)})
352
+ turn_metrics.morph_api_ms = int((time.monotonic() - model_start) * 1000)
353
+ timings.turns.append(turn_metrics)
354
+ break
355
+ turn_metrics.morph_api_ms = int((time.monotonic() - model_start) * 1000)
356
+
357
+ tool_calls: list[ToolCallRef] = response["tool_calls"]
358
+ messages.append(
359
+ ChatMessage(
360
+ role="assistant",
361
+ content=response["content"],
362
+ tool_calls=tool_calls or None,
363
+ )
364
+ )
365
+
366
+ if not tool_calls:
367
+ errors.append({"message": "No tool calls produced by the model."})
368
+ termination_reason = "terminated"
369
+ timings.turns.append(turn_metrics)
370
+ break
371
+
372
+ yield WarpGrepStep(
373
+ turn=turn,
374
+ tool_calls=[
375
+ {"name": tc.name, "arguments": _safe_parse_json(tc.arguments)}
376
+ for tc in tool_calls
377
+ ],
378
+ )
379
+
380
+ finish_call = next((tc for tc in tool_calls if tc.name == "finish"), None)
381
+ if finish_call is not None:
382
+ args = _safe_parse_json(finish_call.arguments)
383
+ files_str = str(args.get("files") or "")
384
+ files = parse_finish_files(files_str)
385
+ finish_meta = AgentFinish(files=files)
386
+ termination_reason = "completed"
387
+
388
+ if not files:
389
+ payload = files_str or "No relevant code found."
390
+ timings.turns.append(turn_metrics)
391
+ timings.total_ms = int((time.monotonic() - total_start) * 1000)
392
+ yield AgentRunResult(
393
+ termination_reason="completed",
394
+ messages=messages,
395
+ finish_payload=payload,
396
+ finish_metadata=finish_meta,
397
+ timings=timings,
398
+ )
399
+ return
400
+
401
+ timings.turns.append(turn_metrics)
402
+ break
403
+
404
+ tool_start = time.monotonic()
405
+ results = await asyncio.gather(
406
+ *(_run_one_tool(provider, tc, repo) for tc in tool_calls)
407
+ )
408
+ turn_metrics.local_tools_ms = int((time.monotonic() - tool_start) * 1000)
409
+
410
+ for tool_call_id, content in results:
411
+ messages.append(ChatMessage(role="tool", content=content, tool_call_id=tool_call_id))
412
+
413
+ turn_msg = format_turn_message(turn, turn_cap)
414
+ budget = calculate_context_budget(messages)
415
+ messages.append(ChatMessage(role="user", content=turn_msg + "\n" + budget))
416
+ timings.turns.append(turn_metrics)
417
+
418
+ if termination_reason != "completed" or finish_meta is None:
419
+ timings.total_ms = int((time.monotonic() - total_start) * 1000)
420
+ yield AgentRunResult(
421
+ termination_reason="terminated" if termination_reason != "error" else "error",
422
+ messages=messages,
423
+ errors=errors,
424
+ timings=timings,
425
+ )
426
+ return
427
+
428
+ # Build finish payload + resolve file contents for returned ranges.
429
+ parts = ["Relevant context found:"]
430
+ for f in finish_meta.files:
431
+ if f.lines == "*":
432
+ ranges = "*"
433
+ elif isinstance(f.lines, list):
434
+ ranges = ", ".join(f"{s}-{e}" for s, e in f.lines)
435
+ else:
436
+ ranges = "*"
437
+ parts.append(f"- {f.path}: {ranges}")
438
+ payload = "\n".join(parts)
439
+
440
+ finish_start = time.monotonic()
441
+ file_read_errors: list[dict[str, str]] = []
442
+
443
+ async def reader(p: str, s: int | None, e: int | None) -> list[str]:
444
+ resolved_path = p
445
+ if not p.startswith(repo):
446
+ relative = p[1:] if p.startswith("/") else p
447
+ resolved_path = os.path.join(repo, relative)
448
+ rr = await provider.read(path=resolved_path, start_line=s, end_line=e)
449
+ if rr.error:
450
+ file_read_errors.append({"path": resolved_path, "error": rr.error})
451
+ return [f"[couldn't find: {resolved_path}]"]
452
+ out: list[str] = []
453
+ for line in rr.lines:
454
+ idx = line.find("|")
455
+ out.append(line[idx + 1 :] if idx >= 0 else line)
456
+ return out
457
+
458
+ resolved_raw = await read_finish_files(finish_meta.files, reader)
459
+ timings.finish_resolution_ms = int((time.monotonic() - finish_start) * 1000)
460
+
461
+ if file_read_errors:
462
+ errors.extend(
463
+ {"message": f"File read error: {e['path']} - {e['error']}"} for e in file_read_errors
464
+ )
465
+
466
+ resolved = [
467
+ ResolvedContext(path=r["path"], ranges=r["ranges"], content=r["content"]) # type: ignore[arg-type]
468
+ for r in resolved_raw
469
+ ]
470
+ timings.total_ms = int((time.monotonic() - total_start) * 1000)
471
+ yield AgentRunResult(
472
+ termination_reason="completed",
473
+ messages=messages,
474
+ finish_payload=payload,
475
+ finish_metadata=finish_meta,
476
+ resolved=resolved,
477
+ errors=errors,
478
+ timings=timings,
479
+ )
480
+
481
+
482
+ async def _run_one_tool(
483
+ provider: WarpGrepProvider, tc: ToolCallRef, repo_root: str
484
+ ) -> tuple[str, str]:
485
+ args = _safe_parse_json(tc.arguments)
486
+ try:
487
+ output = await execute_tool(provider, tc.name, args, repo_root)
488
+ except Exception as err: # noqa: BLE001 - mirror TS .catch(err => String(err))
489
+ output = str(err)
490
+ return tc.id, output
491
+
492
+
493
+ async def run_warp_grep(
494
+ *,
495
+ search_term: str,
496
+ repo_root: str,
497
+ provider: WarpGrepProvider,
498
+ api_key: str,
499
+ api_url: str | None = None,
500
+ model: str | None = None,
501
+ timeout: float | None = None,
502
+ search_type: str | None = None,
503
+ max_turns: int | None = None,
504
+ max_retries: int = 3,
505
+ ) -> AgentRunResult:
506
+ """Non-streaming convenience wrapper: drain the streaming generator and
507
+ return the final :class:`AgentRunResult`."""
508
+ result: AgentRunResult | None = None
509
+ async for item in run_warp_grep_streaming(
510
+ search_term=search_term,
511
+ repo_root=repo_root,
512
+ provider=provider,
513
+ api_key=api_key,
514
+ api_url=api_url,
515
+ model=model,
516
+ timeout=timeout,
517
+ search_type=search_type,
518
+ max_turns=max_turns,
519
+ max_retries=max_retries,
520
+ ):
521
+ if isinstance(item, AgentRunResult):
522
+ result = item
523
+ assert result is not None # the generator always yields a final result
524
+ return result