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.
- morphsdk/__init__.py +54 -0
- morphsdk/_agent/__init__.py +64 -0
- morphsdk/_agent/config.py +52 -0
- morphsdk/_agent/explore.py +276 -0
- morphsdk/_agent/github.py +57 -0
- morphsdk/_agent/helpers.py +133 -0
- morphsdk/_agent/parser.py +163 -0
- morphsdk/_agent/runner.py +524 -0
- morphsdk/_agent/tools.py +171 -0
- morphsdk/_agent/types.py +126 -0
- morphsdk/_base.py +309 -0
- morphsdk/_client.py +245 -0
- morphsdk/_config.py +37 -0
- morphsdk/_constants.py +53 -0
- morphsdk/_errors.py +111 -0
- morphsdk/_providers/__init__.py +36 -0
- morphsdk/_providers/_filter.py +92 -0
- morphsdk/_providers/base.py +94 -0
- morphsdk/_providers/code_storage_http.py +104 -0
- morphsdk/_providers/local.py +270 -0
- morphsdk/_providers/remote.py +161 -0
- morphsdk/_version.py +1 -0
- morphsdk/adapters/__init__.py +1 -0
- morphsdk/adapters/anthropic.py +360 -0
- morphsdk/adapters/langchain.py +120 -0
- morphsdk/adapters/openai.py +500 -0
- morphsdk/py.typed +0 -0
- morphsdk/resources/__init__.py +0 -0
- morphsdk/resources/browser.py +919 -0
- morphsdk/resources/compact.py +133 -0
- morphsdk/resources/edit.py +506 -0
- morphsdk/resources/explore.py +333 -0
- morphsdk/resources/git.py +861 -0
- morphsdk/resources/github.py +1214 -0
- morphsdk/resources/grep.py +583 -0
- morphsdk/resources/mobile.py +134 -0
- morphsdk/resources/reflex.py +414 -0
- morphsdk/resources/router.py +124 -0
- morphsdk/resources/search.py +110 -0
- morphsdk/tracing/__init__.py +70 -0
- morphsdk/tracing/_otel.py +101 -0
- morphsdk/tracing/core.py +249 -0
- morphsdk/tracing/interaction.py +284 -0
- morphsdk/tracing/otel.py +75 -0
- morphsdk/tracing/reflex.py +58 -0
- morphsdk/tracing/types.py +163 -0
- morphsdk/types/__init__.py +140 -0
- morphsdk/types/browser.py +118 -0
- morphsdk/types/compact.py +41 -0
- morphsdk/types/edit.py +31 -0
- morphsdk/types/explore.py +42 -0
- morphsdk/types/git.py +25 -0
- morphsdk/types/github.py +111 -0
- morphsdk/types/grep.py +41 -0
- morphsdk/types/mobile.py +25 -0
- morphsdk/types/reflex.py +137 -0
- morphsdk/types/router.py +21 -0
- morphsdk/types/search.py +33 -0
- morphsdk-0.2.5.dist-info/METADATA +226 -0
- morphsdk-0.2.5.dist-info/RECORD +61 -0
- 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
|