turnloop 0.1.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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
turnloop/tools/task.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Task — delegate to a subagent.
|
|
2
|
+
|
|
3
|
+
`parallel_safe = True`, so several Task calls in one assistant message fan out
|
|
4
|
+
concurrently. The bound is the provider's `max_concurrent_requests`, applied
|
|
5
|
+
through a limiter created once per provider — on the GLM deployment that is vLLM's
|
|
6
|
+
`--max-num-seqs 16` for the whole process, and the parent's own in-flight request
|
|
7
|
+
counts as one of them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
15
|
+
|
|
16
|
+
SUBAGENT_TYPES = ("general", "explore", "verify")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskArgs(BaseModel):
|
|
20
|
+
description: str = Field(description="3-5 word label shown to the user.")
|
|
21
|
+
prompt: str = Field(
|
|
22
|
+
description="The full task. The subagent sees only this — include all context it needs."
|
|
23
|
+
)
|
|
24
|
+
subagent_type: str = Field(
|
|
25
|
+
default="general",
|
|
26
|
+
description="'general' (default), 'explore' (read-only search), 'verify' (check work).",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskTool(Tool):
|
|
31
|
+
name = "Task"
|
|
32
|
+
Args = TaskArgs
|
|
33
|
+
read_only = False # a general subagent can write
|
|
34
|
+
parallel_safe = True
|
|
35
|
+
timeout_s = None # bounded by the child's own iteration cap
|
|
36
|
+
|
|
37
|
+
descriptions = {
|
|
38
|
+
"terse": """
|
|
39
|
+
Delegate a task to a subagent with its own context. Only its final answer returns
|
|
40
|
+
to you. Types: general, explore (read-only search), verify. Several Task calls in
|
|
41
|
+
one turn run in parallel.
|
|
42
|
+
""",
|
|
43
|
+
"normal": """
|
|
44
|
+
Delegate work to a subagent that has its own context window.
|
|
45
|
+
|
|
46
|
+
- The subagent sees only your `prompt` — it cannot see this conversation. Include
|
|
47
|
+
every piece of context it needs.
|
|
48
|
+
- Only its final message comes back to you. Its intermediate tool calls never
|
|
49
|
+
enter your context, which is the point: you pay for the answer, not the search.
|
|
50
|
+
- It cannot ask you or the user questions, so make the task self-contained.
|
|
51
|
+
- Types: `general` (full tools), `explore` (read-only, for searching), `verify`
|
|
52
|
+
(checks whether work is actually complete).
|
|
53
|
+
- Issue several Task calls in one turn to run them in parallel.
|
|
54
|
+
- Use it for: broad searches that would flood your context, and independent pieces
|
|
55
|
+
of work. Do not use it for a task you could finish in two tool calls — the
|
|
56
|
+
overhead is real.
|
|
57
|
+
""",
|
|
58
|
+
"verbose": """
|
|
59
|
+
Delegate work to a subagent that has its own context window.
|
|
60
|
+
|
|
61
|
+
Arguments:
|
|
62
|
+
- `description`: a 3-5 word label shown to the user while it runs.
|
|
63
|
+
- `prompt`: the complete task. This is all the subagent will see.
|
|
64
|
+
- `subagent_type`: `general`, `explore`, or `verify`.
|
|
65
|
+
|
|
66
|
+
How it works:
|
|
67
|
+
- The subagent starts fresh. It cannot see this conversation, your files, or your
|
|
68
|
+
reasoning. Anything it needs must be in `prompt`, including file paths and any
|
|
69
|
+
constraint you have already established.
|
|
70
|
+
- It runs its own tool loop and returns a single final message. Its intermediate
|
|
71
|
+
steps never enter your context — that is the entire economic argument for using
|
|
72
|
+
it. A child that reads twenty files and reports three line numbers costs you
|
|
73
|
+
three line numbers.
|
|
74
|
+
- It cannot spawn further subagents and cannot ask questions. Make the task
|
|
75
|
+
self-contained, and tell it what to do if it hits an ambiguity.
|
|
76
|
+
- Its work counts against the same permission rules you are subject to.
|
|
77
|
+
|
|
78
|
+
Subagent types:
|
|
79
|
+
- `general`: all tools, including writes. For self-contained implementation work.
|
|
80
|
+
- `explore`: read-only tools only. For "where is X", "what calls Y", "map this
|
|
81
|
+
directory". Safe to fan out, since it cannot modify anything.
|
|
82
|
+
- `verify`: for checking whether stated work is actually complete — runs tests,
|
|
83
|
+
reads the changed files, reports what it observed.
|
|
84
|
+
|
|
85
|
+
Parallelism:
|
|
86
|
+
- Several Task calls in one assistant message run concurrently, bounded by the
|
|
87
|
+
provider's concurrency limit.
|
|
88
|
+
- Give each one a genuinely independent task. Two children editing the same file
|
|
89
|
+
will conflict, and neither knows about the other.
|
|
90
|
+
|
|
91
|
+
When not to use it:
|
|
92
|
+
- Reading two known files. Just read them.
|
|
93
|
+
- Anything needing back-and-forth with the user.
|
|
94
|
+
- Work where you need to see the intermediate steps to judge the result.
|
|
95
|
+
""",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def run(self, args: TaskArgs, ctx: ToolContext) -> ToolOutput:
|
|
99
|
+
if ctx.depth >= 1:
|
|
100
|
+
return ToolOutput.error(
|
|
101
|
+
"A subagent cannot spawn further subagents. Do this work directly."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
provider = ctx.extras.get("provider")
|
|
105
|
+
registry = ctx.extras.get("registry")
|
|
106
|
+
ui = ctx.extras.get("ui")
|
|
107
|
+
if provider is None or registry is None or ui is None:
|
|
108
|
+
return ToolOutput.error("subagents are not available in this context")
|
|
109
|
+
|
|
110
|
+
subagent_type = args.subagent_type if args.subagent_type in SUBAGENT_TYPES else "general"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
limiter = ctx.extras.get("limiter")
|
|
114
|
+
if limiter is not None:
|
|
115
|
+
async with limiter:
|
|
116
|
+
result = await self._spawn(args, ctx, provider, registry, ui, subagent_type)
|
|
117
|
+
else:
|
|
118
|
+
result = await self._spawn(args, ctx, provider, registry, ui, subagent_type)
|
|
119
|
+
|
|
120
|
+
note = "\n\n[answer was condensed to fit]" if result.truncated else ""
|
|
121
|
+
return ToolOutput(
|
|
122
|
+
content=result.text + note,
|
|
123
|
+
display=(
|
|
124
|
+
f"{args.description} — {result.turns} turn(s), "
|
|
125
|
+
f"{result.tool_calls} tool call(s), ${result.cost_usd:.4f}"
|
|
126
|
+
),
|
|
127
|
+
metrics={
|
|
128
|
+
"subagent_type": subagent_type,
|
|
129
|
+
"turns": result.turns,
|
|
130
|
+
"tool_calls": result.tool_calls,
|
|
131
|
+
"cost_usd": result.cost_usd,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def _spawn(self, args: TaskArgs, ctx: ToolContext, provider, registry, ui,
|
|
136
|
+
subagent_type: str):
|
|
137
|
+
from turnloop.agent.subagent import run_subagent
|
|
138
|
+
|
|
139
|
+
return await run_subagent(
|
|
140
|
+
prompt=args.prompt,
|
|
141
|
+
subagent_type=subagent_type,
|
|
142
|
+
provider=provider,
|
|
143
|
+
registry=registry,
|
|
144
|
+
permissions=ctx.permissions,
|
|
145
|
+
settings=ctx.settings,
|
|
146
|
+
cwd=ctx.cwd,
|
|
147
|
+
ui=ui,
|
|
148
|
+
parent_session=ctx.session,
|
|
149
|
+
limiter=ctx.extras.get("limiter"),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def summary(self, args: TaskArgs) -> str: # type: ignore[override]
|
|
153
|
+
return f"Task[{args.subagent_type}] {args.description}"
|
|
154
|
+
|
|
155
|
+
def permission_target(self, args: TaskArgs) -> str: # type: ignore[override]
|
|
156
|
+
return args.subagent_type
|
|
157
|
+
|
|
158
|
+
def is_read_only_for(self, args: TaskArgs) -> bool: # type: ignore[override]
|
|
159
|
+
# An explore subagent has only read-only tools, so delegating to one is
|
|
160
|
+
# itself a read-only act — which makes it usable in plan mode.
|
|
161
|
+
return args.subagent_type == "explore"
|
turnloop/tools/textio.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Text file IO that does not mangle line endings.
|
|
2
|
+
|
|
3
|
+
On Windows this is not a nicety. Python's default text mode translates "\\n" to
|
|
4
|
+
"\\r\\n" on write, so a tool that reads a LF file and writes it back produces a
|
|
5
|
+
diff on every single line. Conversely, writing LF into a CRLF repository does the
|
|
6
|
+
same in reverse. Both make a one-line edit look like a rewrite in review.
|
|
7
|
+
|
|
8
|
+
So: read bytes, decode explicitly, and write back with the newline style the file
|
|
9
|
+
already had.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CRLF = "\r\n"
|
|
17
|
+
LF = "\n"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
BOM = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_text(path: Path) -> str:
|
|
24
|
+
"""Decode a file without newline translation, dropping any BOM.
|
|
25
|
+
|
|
26
|
+
Windows tooling writes UTF-8 with a BOM freely (PowerShell's `Set-Content
|
|
27
|
+
-Encoding utf8` does it by default). Leaving it in the string puts an invisible
|
|
28
|
+
U+FEFF at the start of line 1, which makes `ast.parse` fail with "invalid
|
|
29
|
+
non-printable character" and makes an exact-match Edit on the first line fail
|
|
30
|
+
for no visible reason. `has_bom` + `write_text` put it back.
|
|
31
|
+
"""
|
|
32
|
+
return path.read_bytes().decode("utf-8-sig", errors="replace")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def has_bom(path: Path) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
return path.read_bytes().startswith(b"\xef\xbb\xbf")
|
|
38
|
+
except OSError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect_newline(path: Path) -> str:
|
|
43
|
+
"""The dominant newline style of an existing file, defaulting to LF."""
|
|
44
|
+
try:
|
|
45
|
+
head = path.read_bytes()[:65_536]
|
|
46
|
+
except OSError:
|
|
47
|
+
return LF
|
|
48
|
+
crlf = head.count(b"\r\n")
|
|
49
|
+
lf = head.count(b"\n") - crlf
|
|
50
|
+
if crlf > lf:
|
|
51
|
+
return CRLF
|
|
52
|
+
return LF
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ends_with_newline(text: str) -> bool:
|
|
56
|
+
return text.endswith(("\n", "\r"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_text(path: Path, content: str, newline: str | None = None,
|
|
60
|
+
bom: bool = False) -> None:
|
|
61
|
+
"""Write text with an explicit newline style, in binary mode.
|
|
62
|
+
|
|
63
|
+
`newline=None` means "whatever the string already contains" — used for new
|
|
64
|
+
files, where the caller's content is authoritative. `bom` restores a byte-order
|
|
65
|
+
mark the file already had, so editing a BOM'd file does not silently re-encode it.
|
|
66
|
+
"""
|
|
67
|
+
if newline:
|
|
68
|
+
normalized = content.replace(CRLF, LF).replace("\r", LF)
|
|
69
|
+
if newline != LF:
|
|
70
|
+
normalized = normalized.replace(LF, newline)
|
|
71
|
+
else:
|
|
72
|
+
normalized = content
|
|
73
|
+
normalized = normalized.removeprefix(BOM)
|
|
74
|
+
path.write_bytes((BOM + normalized if bom else normalized).encode("utf-8"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def preserve_trailing_newline(original: str, updated: str) -> str:
|
|
78
|
+
"""Keep the original file's trailing-newline convention.
|
|
79
|
+
|
|
80
|
+
A model that drops the final newline creates a spurious "\\ No newline at end
|
|
81
|
+
of file" hunk in every subsequent diff.
|
|
82
|
+
"""
|
|
83
|
+
if ends_with_newline(original) and not ends_with_newline(updated):
|
|
84
|
+
return updated + ("\r\n" if original.endswith(CRLF) else "\n")
|
|
85
|
+
if not ends_with_newline(original) and ends_with_newline(updated):
|
|
86
|
+
return updated.rstrip("\r\n")
|
|
87
|
+
return updated
|
turnloop/tools/todo.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""TodoWrite — the agent's externalized plan.
|
|
2
|
+
|
|
3
|
+
Cheap and unglamorous, and it changes behavior more than its size suggests: a
|
|
4
|
+
written plan survives compaction (it is preserved verbatim), it stops the model
|
|
5
|
+
from silently dropping the third of four requested items, and it gives the user
|
|
6
|
+
something to watch during a long turn.
|
|
7
|
+
|
|
8
|
+
Validating "exactly one in_progress" is not pedantry. A model that marks six items
|
|
9
|
+
in progress has stopped tracking anything.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from turnloop.sessions.models import Todo
|
|
17
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TodoWriteArgs(BaseModel):
|
|
21
|
+
todos: list[Todo] = Field(description="The complete task list, replacing the previous one.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TodoWriteTool(Tool):
|
|
25
|
+
name = "TodoWrite"
|
|
26
|
+
Args = TodoWriteArgs
|
|
27
|
+
read_only = True # touches session state only, never the filesystem
|
|
28
|
+
parallel_safe = False
|
|
29
|
+
|
|
30
|
+
descriptions = {
|
|
31
|
+
"terse": """
|
|
32
|
+
Maintain the task list. Send the whole list every time. Exactly one item
|
|
33
|
+
in_progress. Mark items completed as you finish them.
|
|
34
|
+
""",
|
|
35
|
+
"normal": """
|
|
36
|
+
Track multi-step work.
|
|
37
|
+
|
|
38
|
+
- Send the complete list on every call; it replaces the previous one.
|
|
39
|
+
- Each item has `content` (imperative: "Add the retry test"), `activeForm`
|
|
40
|
+
(present continuous: "Adding the retry test"), and `status` (pending,
|
|
41
|
+
in_progress, completed).
|
|
42
|
+
- Exactly one item may be in_progress.
|
|
43
|
+
- Mark an item completed as soon as it is done, not in a batch at the end.
|
|
44
|
+
- Use this for work with three or more steps, or when the user listed several
|
|
45
|
+
things. Skip it for single-step tasks — the overhead is not worth it.
|
|
46
|
+
""",
|
|
47
|
+
"verbose": """
|
|
48
|
+
Track multi-step work by maintaining a task list.
|
|
49
|
+
|
|
50
|
+
Arguments:
|
|
51
|
+
- `todos`: the complete list. Every call replaces the whole list, so include the
|
|
52
|
+
items that have not changed.
|
|
53
|
+
|
|
54
|
+
Each item:
|
|
55
|
+
- `content`: imperative form — "Add the retry test", "Fix the CRLF handling".
|
|
56
|
+
- `activeForm`: present continuous, shown to the user while it runs — "Adding the
|
|
57
|
+
retry test".
|
|
58
|
+
- `status`: `pending`, `in_progress`, or `completed`.
|
|
59
|
+
|
|
60
|
+
Rules:
|
|
61
|
+
- Exactly one item is in_progress at a time. Marking several defeats the purpose.
|
|
62
|
+
- Mark an item completed immediately on finishing it. Batching completions at the
|
|
63
|
+
end means the list is wrong for the whole session.
|
|
64
|
+
- Do not mark something completed if it partially works, if tests fail, or if you
|
|
65
|
+
could not verify it. Leave it in progress and say what is blocking.
|
|
66
|
+
- Add newly discovered work as new items rather than expanding an existing one.
|
|
67
|
+
|
|
68
|
+
When to use it: three or more distinct steps, or the user listed several things.
|
|
69
|
+
When not to: a single edit, a question, or something purely conversational — the
|
|
70
|
+
list would be noise.
|
|
71
|
+
|
|
72
|
+
This list survives context compaction verbatim, so it is also how you remember
|
|
73
|
+
your own plan across a long session.
|
|
74
|
+
""",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def run(self, args: TodoWriteArgs, ctx: ToolContext) -> ToolOutput:
|
|
78
|
+
in_progress = [t for t in args.todos if t.status == "in_progress"]
|
|
79
|
+
if len(in_progress) > 1:
|
|
80
|
+
return ToolOutput.error(
|
|
81
|
+
f"{len(in_progress)} items are in_progress: "
|
|
82
|
+
f"{', '.join(t.content[:40] for t in in_progress)}. "
|
|
83
|
+
"Exactly one item may be in progress — pick the one you are actually working on."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
for todo in args.todos:
|
|
87
|
+
if not todo.activeForm:
|
|
88
|
+
todo.activeForm = _to_active(todo.content)
|
|
89
|
+
|
|
90
|
+
previous = {t.content: t.status for t in ctx.session.todos}
|
|
91
|
+
ctx.session.set_todos(args.todos)
|
|
92
|
+
|
|
93
|
+
newly_done = [
|
|
94
|
+
t.content for t in args.todos
|
|
95
|
+
if t.status == "completed" and previous.get(t.content) != "completed"
|
|
96
|
+
]
|
|
97
|
+
counts = {
|
|
98
|
+
status: sum(1 for t in args.todos if t.status == status)
|
|
99
|
+
for status in ("pending", "in_progress", "completed")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines = [f"Task list updated: {counts['completed']} done, "
|
|
103
|
+
f"{counts['in_progress']} in progress, {counts['pending']} pending."]
|
|
104
|
+
if newly_done:
|
|
105
|
+
lines.append("Completed: " + "; ".join(newly_done))
|
|
106
|
+
if in_progress:
|
|
107
|
+
lines.append(f"Now: {in_progress[0].activeForm}")
|
|
108
|
+
|
|
109
|
+
return ToolOutput(
|
|
110
|
+
content="\n".join(lines),
|
|
111
|
+
display=ctx.session.todo_summary(),
|
|
112
|
+
metrics={"todos": len(args.todos), **counts},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def summary(self, args: TodoWriteArgs) -> str: # type: ignore[override]
|
|
116
|
+
active = next((t for t in args.todos if t.status == "in_progress"), None)
|
|
117
|
+
return f"TodoWrite — {active.activeForm}" if active else "TodoWrite"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _to_active(content: str) -> str:
|
|
121
|
+
"""Derive a present-continuous form when the model omits one."""
|
|
122
|
+
words = content.split()
|
|
123
|
+
if not words:
|
|
124
|
+
return content
|
|
125
|
+
verb = words[0]
|
|
126
|
+
lower = verb.lower()
|
|
127
|
+
irregular = {
|
|
128
|
+
"add": "Adding", "fix": "Fixing", "run": "Running", "write": "Writing",
|
|
129
|
+
"make": "Making", "get": "Getting", "set": "Setting", "put": "Putting",
|
|
130
|
+
"create": "Creating", "update": "Updating", "remove": "Removing",
|
|
131
|
+
"delete": "Deleting", "refactor": "Refactoring", "test": "Testing",
|
|
132
|
+
"implement": "Implementing", "verify": "Verifying", "check": "Checking",
|
|
133
|
+
}
|
|
134
|
+
if lower in irregular:
|
|
135
|
+
head = irregular[lower]
|
|
136
|
+
elif lower.endswith("e") and not lower.endswith(("ee", "ye", "oe")):
|
|
137
|
+
head = verb[:-1].capitalize() + "ing"
|
|
138
|
+
else:
|
|
139
|
+
head = verb.capitalize() + "ing"
|
|
140
|
+
return " ".join([head, *words[1:]])
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""WebFetch — retrieve a URL and answer a question about it.
|
|
2
|
+
|
|
3
|
+
The design constraint is context, not networking. A documentation page is commonly
|
|
4
|
+
50-150k characters; dropping that into a 57k-token window destroys the session. So
|
|
5
|
+
the page is never returned raw: it is converted to text, capped, and then passed
|
|
6
|
+
through the model with the caller's question, and only that answer enters the
|
|
7
|
+
conversation.
|
|
8
|
+
|
|
9
|
+
Redirects are followed only within the same host by default. A tool that fetches
|
|
10
|
+
arbitrary URLs on a model's say-so is an SSRF primitive, and cross-host redirects
|
|
11
|
+
are how that gets exploited.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from html.parser import HTMLParser
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
|
|
23
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
24
|
+
|
|
25
|
+
MAX_CHARS = 100_000
|
|
26
|
+
CACHE_TTL_S = 15 * 60
|
|
27
|
+
BLOCKED_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "169.254.169.254"}
|
|
28
|
+
|
|
29
|
+
_cache: dict[str, tuple[float, str]] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WebFetchArgs(BaseModel):
|
|
33
|
+
url: str = Field(description="The URL to fetch. Must be http or https.")
|
|
34
|
+
prompt: str = Field(description="What to extract or answer from the page.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebFetchTool(Tool):
|
|
38
|
+
name = "WebFetch"
|
|
39
|
+
Args = WebFetchArgs
|
|
40
|
+
read_only = True
|
|
41
|
+
parallel_safe = True
|
|
42
|
+
timeout_s = 120.0
|
|
43
|
+
|
|
44
|
+
descriptions = {
|
|
45
|
+
"terse": """
|
|
46
|
+
Fetch a URL and answer `prompt` about its content. Returns the answer, not the
|
|
47
|
+
page. Use for documentation and specific pages you have a URL for.
|
|
48
|
+
""",
|
|
49
|
+
"normal": """
|
|
50
|
+
Fetch a web page and extract what you asked for.
|
|
51
|
+
|
|
52
|
+
- `url` must be http or https.
|
|
53
|
+
- `prompt` says what to extract; the page is summarized against it and you receive
|
|
54
|
+
the answer, not the raw HTML.
|
|
55
|
+
- Results are cached for 15 minutes.
|
|
56
|
+
- Use this when you have a specific URL — documentation, an issue, a spec. It is
|
|
57
|
+
not a search engine.
|
|
58
|
+
""",
|
|
59
|
+
"verbose": """
|
|
60
|
+
Fetch a web page and extract what you asked for.
|
|
61
|
+
|
|
62
|
+
Arguments:
|
|
63
|
+
- `url`: an http or https URL.
|
|
64
|
+
- `prompt`: what you want from the page — "the migration steps for v3", "the
|
|
65
|
+
signature of the retry decorator".
|
|
66
|
+
|
|
67
|
+
Behavior:
|
|
68
|
+
- HTML is converted to text (scripts, styles and navigation removed) and capped at
|
|
69
|
+
100,000 characters.
|
|
70
|
+
- That text is then passed through the model together with your prompt, and you
|
|
71
|
+
receive the extracted answer. The raw page never enters the conversation, because
|
|
72
|
+
a single documentation page can be larger than the entire context window.
|
|
73
|
+
- Redirects are followed only within the same host. A cross-host redirect is
|
|
74
|
+
reported rather than followed.
|
|
75
|
+
- Requests to localhost and link-local addresses are refused.
|
|
76
|
+
- Responses are cached for 15 minutes, so re-fetching the same URL in one session
|
|
77
|
+
is free.
|
|
78
|
+
|
|
79
|
+
This is not a search engine. If you do not have a URL, ask the user for one.
|
|
80
|
+
""",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async def run(self, args: WebFetchArgs, ctx: ToolContext) -> ToolOutput:
|
|
84
|
+
parsed = urlparse(args.url)
|
|
85
|
+
if parsed.scheme not in ("http", "https"):
|
|
86
|
+
return ToolOutput.error(f"unsupported scheme {parsed.scheme!r}; use http or https")
|
|
87
|
+
host = (parsed.hostname or "").lower()
|
|
88
|
+
if host in BLOCKED_HOSTS or host.endswith(".local"):
|
|
89
|
+
return ToolOutput.error(
|
|
90
|
+
f"refusing to fetch {host}: local and link-local addresses are blocked"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
cached = _cache.get(args.url)
|
|
94
|
+
if cached and time.monotonic() - cached[0] < CACHE_TTL_S:
|
|
95
|
+
text = cached[1]
|
|
96
|
+
from_cache = True
|
|
97
|
+
else:
|
|
98
|
+
try:
|
|
99
|
+
async with httpx.AsyncClient(
|
|
100
|
+
timeout=httpx.Timeout(30.0, connect=10.0), follow_redirects=False
|
|
101
|
+
) as client:
|
|
102
|
+
response = await client.get(
|
|
103
|
+
args.url, headers={"user-agent": "turnloop/0.1 (+coding agent)"}
|
|
104
|
+
)
|
|
105
|
+
if response.is_redirect:
|
|
106
|
+
location = response.headers.get("location", "")
|
|
107
|
+
target_host = (urlparse(location).hostname or host).lower()
|
|
108
|
+
if target_host != host:
|
|
109
|
+
return ToolOutput(
|
|
110
|
+
content=(
|
|
111
|
+
f"{args.url} redirects to a different host: {location}\n"
|
|
112
|
+
"Not following it automatically. Call WebFetch again with "
|
|
113
|
+
"that URL if you want it."
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
response = await client.get(location)
|
|
117
|
+
except httpx.HTTPError as exc:
|
|
118
|
+
return ToolOutput.error(f"fetch failed: {type(exc).__name__}: {exc}")
|
|
119
|
+
|
|
120
|
+
if response.status_code >= 400:
|
|
121
|
+
return ToolOutput.error(f"HTTP {response.status_code} from {args.url}")
|
|
122
|
+
|
|
123
|
+
content_type = response.headers.get("content-type", "")
|
|
124
|
+
body = response.text
|
|
125
|
+
text = html_to_text(body) if "html" in content_type else body
|
|
126
|
+
text = text[:MAX_CHARS]
|
|
127
|
+
_cache[args.url] = (time.monotonic(), text)
|
|
128
|
+
from_cache = False
|
|
129
|
+
|
|
130
|
+
if not text.strip():
|
|
131
|
+
return ToolOutput.error(f"{args.url} returned no readable text")
|
|
132
|
+
|
|
133
|
+
answer = await self._extract(text, args.prompt, ctx)
|
|
134
|
+
return ToolOutput(
|
|
135
|
+
content=answer,
|
|
136
|
+
display=f"WebFetch {args.url} ({len(text):,} chars{' cached' if from_cache else ''})",
|
|
137
|
+
metrics={"chars": len(text), "cached": from_cache},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def _extract(self, text: str, prompt: str, ctx: ToolContext) -> str:
|
|
141
|
+
"""Summarize the page against the prompt using the session's provider."""
|
|
142
|
+
provider = ctx.extras.get("provider")
|
|
143
|
+
if provider is None:
|
|
144
|
+
return f"(no provider available to summarize; first 4000 chars)\n\n{text[:4000]}"
|
|
145
|
+
|
|
146
|
+
from turnloop.core.events import MessageDone
|
|
147
|
+
from turnloop.core.messages import Message
|
|
148
|
+
from turnloop.providers.base import CompletionRequest
|
|
149
|
+
|
|
150
|
+
request = CompletionRequest(
|
|
151
|
+
messages=[
|
|
152
|
+
Message.user_text(
|
|
153
|
+
f"{prompt}\n\nAnswer only from the page content below. If it does not "
|
|
154
|
+
f"contain the answer, say so.\n\n---\n{text[:60_000]}"
|
|
155
|
+
)
|
|
156
|
+
],
|
|
157
|
+
system=["Extract exactly what was asked from the supplied page. Be concise."],
|
|
158
|
+
tools=[],
|
|
159
|
+
max_tokens=2_000,
|
|
160
|
+
temperature=0.0,
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
async for event in provider.stream(request):
|
|
164
|
+
if isinstance(event, MessageDone):
|
|
165
|
+
return event.message.text.strip() or "(the model returned nothing)"
|
|
166
|
+
except Exception as exc: # noqa: BLE001
|
|
167
|
+
return f"(summarization failed: {exc})\n\nFirst 4000 characters:\n{text[:4000]}"
|
|
168
|
+
return text[:4000]
|
|
169
|
+
|
|
170
|
+
def summary(self, args: WebFetchArgs) -> str: # type: ignore[override]
|
|
171
|
+
return f"WebFetch {args.url}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _TextExtractor(HTMLParser):
|
|
175
|
+
"""HTML to text using only the standard library.
|
|
176
|
+
|
|
177
|
+
Not a full renderer, and it does not need to be: headings, paragraphs, list
|
|
178
|
+
items and code blocks carry essentially all the information in technical
|
|
179
|
+
documentation.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
SKIP = {"script", "style", "noscript", "svg", "nav", "footer", "header", "form"}
|
|
183
|
+
BLOCK = {
|
|
184
|
+
"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
185
|
+
"pre", "blockquote", "section", "article", "table",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def __init__(self):
|
|
189
|
+
super().__init__(convert_charrefs=True)
|
|
190
|
+
self.parts: list[str] = []
|
|
191
|
+
self._skip_depth = 0
|
|
192
|
+
|
|
193
|
+
def handle_starttag(self, tag: str, attrs) -> None:
|
|
194
|
+
if tag in self.SKIP:
|
|
195
|
+
self._skip_depth += 1
|
|
196
|
+
elif tag in self.BLOCK:
|
|
197
|
+
self.parts.append("\n")
|
|
198
|
+
if tag == "li":
|
|
199
|
+
self.parts.append("- ")
|
|
200
|
+
|
|
201
|
+
def handle_endtag(self, tag: str) -> None:
|
|
202
|
+
if tag in self.SKIP and self._skip_depth:
|
|
203
|
+
self._skip_depth -= 1
|
|
204
|
+
elif tag in self.BLOCK:
|
|
205
|
+
self.parts.append("\n")
|
|
206
|
+
|
|
207
|
+
def handle_data(self, data: str) -> None:
|
|
208
|
+
if self._skip_depth:
|
|
209
|
+
return
|
|
210
|
+
if data.strip():
|
|
211
|
+
self.parts.append(data)
|
|
212
|
+
|
|
213
|
+
def text(self) -> str:
|
|
214
|
+
joined = "".join(self.parts)
|
|
215
|
+
lines = [" ".join(line.split()) for line in joined.splitlines()]
|
|
216
|
+
out: list[str] = []
|
|
217
|
+
blank = 0
|
|
218
|
+
for line in lines:
|
|
219
|
+
if not line:
|
|
220
|
+
blank += 1
|
|
221
|
+
if blank > 1:
|
|
222
|
+
continue
|
|
223
|
+
else:
|
|
224
|
+
blank = 0
|
|
225
|
+
out.append(line)
|
|
226
|
+
return "\n".join(out).strip()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def html_to_text(html: str) -> str:
|
|
230
|
+
parser = _TextExtractor()
|
|
231
|
+
try:
|
|
232
|
+
parser.feed(html)
|
|
233
|
+
except Exception: # noqa: BLE001 - malformed HTML is the norm
|
|
234
|
+
pass
|
|
235
|
+
return parser.text()
|