stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Daemon workspace operations for explicit client requests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
FILE_LIMIT = 2000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_files(root: Path, limit: int = FILE_LIMIT) -> list[str]:
|
|
15
|
+
"""Workspace paths for `@`, relative to `root`, newest convention first: git."""
|
|
16
|
+
try:
|
|
17
|
+
finished = subprocess.run(
|
|
18
|
+
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
19
|
+
cwd=root,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
timeout=5,
|
|
22
|
+
check=False,
|
|
23
|
+
)
|
|
24
|
+
if finished.returncode == 0 and finished.stdout:
|
|
25
|
+
# -z disables Git's C-style quoting and preserves whitespace in names.
|
|
26
|
+
paths = finished.stdout.rstrip(b"\0").split(b"\0")
|
|
27
|
+
return sorted({os.fsdecode(path) for path in paths})[:limit]
|
|
28
|
+
except (OSError, subprocess.SubprocessError):
|
|
29
|
+
pass
|
|
30
|
+
return _walk(root, limit)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _walk(root: Path, limit: int) -> list[str]:
|
|
34
|
+
"""Every file under `root`, dot-directories skipped and bounded.
|
|
35
|
+
|
|
36
|
+
Bounded because this runs on a directory nobody vetted: a home directory, or a
|
|
37
|
+
workspace with a 40,000-file build output in it, must not turn typing `@` into a
|
|
38
|
+
minute of walking.
|
|
39
|
+
"""
|
|
40
|
+
found: list[str] = []
|
|
41
|
+
for current, directories, filenames in os.walk(root):
|
|
42
|
+
directories[:] = [name for name in directories if not name.startswith(".")]
|
|
43
|
+
for filename in filenames:
|
|
44
|
+
if filename.startswith("."):
|
|
45
|
+
continue
|
|
46
|
+
found.append(str(Path(current, filename).relative_to(root)))
|
|
47
|
+
if len(found) >= limit:
|
|
48
|
+
return sorted(found)
|
|
49
|
+
return sorted(found)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_shell(command: str, cwd: Path, timeout: float) -> dict[str, Any]:
|
|
53
|
+
"""Run a user command in the session workspace; reap its group on timeout/close."""
|
|
54
|
+
timeout = max(1.0, min(120.0, timeout))
|
|
55
|
+
process = await asyncio.create_subprocess_shell(
|
|
56
|
+
command, cwd=cwd, stdin=asyncio.subprocess.DEVNULL,
|
|
57
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
|
58
|
+
start_new_session=True,
|
|
59
|
+
)
|
|
60
|
+
# Drain continuously but bound the retained output, including single huge lines.
|
|
61
|
+
output = bytearray()
|
|
62
|
+
truncated = False
|
|
63
|
+
|
|
64
|
+
async def drain() -> None:
|
|
65
|
+
nonlocal truncated
|
|
66
|
+
assert process.stdout is not None
|
|
67
|
+
while chunk := await process.stdout.read(65536):
|
|
68
|
+
remaining = max(0, 1024 * 1024 - len(output))
|
|
69
|
+
output.extend(chunk[:remaining])
|
|
70
|
+
truncated |= len(chunk) > remaining
|
|
71
|
+
await process.wait()
|
|
72
|
+
|
|
73
|
+
pump = asyncio.create_task(drain())
|
|
74
|
+
timed_out = False
|
|
75
|
+
try:
|
|
76
|
+
await asyncio.wait_for(asyncio.shield(pump), timeout)
|
|
77
|
+
except asyncio.TimeoutError:
|
|
78
|
+
timed_out = True
|
|
79
|
+
finally:
|
|
80
|
+
if not pump.done():
|
|
81
|
+
with contextlib.suppress(ProcessLookupError):
|
|
82
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
83
|
+
await pump
|
|
84
|
+
text = output.decode("utf-8", errors="replace").rstrip()
|
|
85
|
+
if truncated:
|
|
86
|
+
text += "\n… output truncated at 1 MiB"
|
|
87
|
+
return {"output": text, "exit_code": 124 if timed_out else process.returncode,
|
|
88
|
+
"timed_out": timeout if timed_out else None}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Harness — the tools, prompts, and skills an agent works with.
|
|
3
|
+
|
|
4
|
+
harness = await Harness.create(cwd=project_root, approval_mode="auto-edit")
|
|
5
|
+
system = harness.system_prompt()
|
|
6
|
+
tools = harness.tool_definitions()
|
|
7
|
+
result = await harness.invoke("read", {"path": "src/main.py"})
|
|
8
|
+
|
|
9
|
+
* `tools/base.py` — `@tool`, and `Runtime[T]`, the parameter the model cannot see.
|
|
10
|
+
* `tools/schema.py` — signature + docstring → JSON Schema.
|
|
11
|
+
* `approvals.py` — the permission vocabulary and the mode policy.
|
|
12
|
+
* `context.py` — `HarnessContext`, the workspace state tools share.
|
|
13
|
+
* `tools/registry.py` — which tools exist and which an agent may see.
|
|
14
|
+
* `skills/` — `SKILL.md` discovery, loaded on demand.
|
|
15
|
+
* `prompts/` — the plan and execute prompts, and the sub-agent briefing.
|
|
16
|
+
* `mcp.py` — external tool servers, adapted to the same `Tool` interface.
|
|
17
|
+
* `harness.py` — the facade that composes all of it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from stcode.core.harness.approvals import (
|
|
21
|
+
APPROVAL_MODES,
|
|
22
|
+
DEFAULT_APPROVAL_MODE,
|
|
23
|
+
ApprovalMode,
|
|
24
|
+
ToolPermission,
|
|
25
|
+
is_forbidden,
|
|
26
|
+
requires_approval,
|
|
27
|
+
)
|
|
28
|
+
from stcode.core.harness.context import HarnessContext, TodoItem
|
|
29
|
+
from stcode.core.harness.harness import Harness, read_project_instructions
|
|
30
|
+
from stcode.core.harness.mcp import MCPManager, MCPServerConfig, load_mcp_config
|
|
31
|
+
from stcode.core.harness.prompts import PromptMode, build_system_prompt, mode_for
|
|
32
|
+
from stcode.core.harness.skills import Skill, SkillRegistry
|
|
33
|
+
from stcode.core.harness.tools import (
|
|
34
|
+
BUILTIN_TOOLS,
|
|
35
|
+
MAIN_TOOLS,
|
|
36
|
+
READ_ONLY_TOOLS,
|
|
37
|
+
WORKER_TOOLS,
|
|
38
|
+
ApprovalRequest,
|
|
39
|
+
Question,
|
|
40
|
+
Runtime,
|
|
41
|
+
Tool,
|
|
42
|
+
ToolError,
|
|
43
|
+
ToolRegistry,
|
|
44
|
+
ToolResult,
|
|
45
|
+
to_tool_definition,
|
|
46
|
+
tool,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"APPROVAL_MODES",
|
|
51
|
+
"BUILTIN_TOOLS",
|
|
52
|
+
"DEFAULT_APPROVAL_MODE",
|
|
53
|
+
"MAIN_TOOLS",
|
|
54
|
+
"READ_ONLY_TOOLS",
|
|
55
|
+
"WORKER_TOOLS",
|
|
56
|
+
"ApprovalMode",
|
|
57
|
+
"ApprovalRequest",
|
|
58
|
+
"Harness",
|
|
59
|
+
"HarnessContext",
|
|
60
|
+
"MCPManager",
|
|
61
|
+
"MCPServerConfig",
|
|
62
|
+
"PromptMode",
|
|
63
|
+
"Question",
|
|
64
|
+
"Runtime",
|
|
65
|
+
"Skill",
|
|
66
|
+
"SkillRegistry",
|
|
67
|
+
"TodoItem",
|
|
68
|
+
"Tool",
|
|
69
|
+
"ToolError",
|
|
70
|
+
"ToolPermission",
|
|
71
|
+
"ToolRegistry",
|
|
72
|
+
"ToolResult",
|
|
73
|
+
"build_system_prompt",
|
|
74
|
+
"is_forbidden",
|
|
75
|
+
"load_mcp_config",
|
|
76
|
+
"mode_for",
|
|
77
|
+
"read_project_instructions",
|
|
78
|
+
"requires_approval",
|
|
79
|
+
"to_tool_definition",
|
|
80
|
+
"tool",
|
|
81
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Approval modes — how much the agent may do without asking.
|
|
3
|
+
|
|
4
|
+
The vocabulary is `core/configs.py`'s; this module owns what each mode permits.
|
|
5
|
+
|
|
6
|
+
`ToolPermission` and the policy below are the enforcement half: a tool declares its
|
|
7
|
+
class of side effect once, at definition, and this module — not the tool — decides
|
|
8
|
+
whether that class runs unattended under the current mode.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from stcode.core.common.compat import StrEnum
|
|
14
|
+
from stcode.core.configs import APPROVAL_MODES, ApprovalMode # noqa: F401 — re-exported
|
|
15
|
+
|
|
16
|
+
DEFAULT_APPROVAL_MODE: ApprovalMode = "suggest"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolPermission(StrEnum):
|
|
20
|
+
"""What class of side effect a tool has — the axis approval modes gate on.
|
|
21
|
+
|
|
22
|
+
Declared once at definition; the policy below decides what it means under the
|
|
23
|
+
current mode. Tools never test the mode themselves — one that knows about
|
|
24
|
+
`full-auto` will eventually disagree with the next one about what it means.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
READ = "read"
|
|
28
|
+
"""Observes the workspace and nothing else. Safe in every mode, `plan` included."""
|
|
29
|
+
|
|
30
|
+
WRITE = "write"
|
|
31
|
+
"""Mutates the filesystem. The thing `plan` exists to forbid."""
|
|
32
|
+
|
|
33
|
+
EXECUTE = "execute"
|
|
34
|
+
"""Runs an arbitrary command or arbitrary Python. Strictly wider than WRITE — a
|
|
35
|
+
shell can write files, and it can also delete the repository."""
|
|
36
|
+
|
|
37
|
+
NETWORK = "network"
|
|
38
|
+
"""Leaves the machine. Gated separately from WRITE because the risk is disclosure,
|
|
39
|
+
not corruption: the workspace survives, its contents may not stay private."""
|
|
40
|
+
|
|
41
|
+
INTERACTIVE = "interactive"
|
|
42
|
+
"""Asks the human something. Always allowed — a mode that blocks the agent from
|
|
43
|
+
asking permission-shaped questions has the gate backwards."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Which permissions run unattended, which ask, and which are refused outright.
|
|
47
|
+
#
|
|
48
|
+
# The ordering is about *workspace mutation*: each mode adds one class to the previous —
|
|
49
|
+
# nothing, then writes, then arbitrary execution. NETWORK is orthogonal (the risk is
|
|
50
|
+
# disclosure, not corruption), so it is gated on its own rather than wedged in.
|
|
51
|
+
_UNATTENDED: dict[ApprovalMode, frozenset[ToolPermission]] = {
|
|
52
|
+
"plan": frozenset({ToolPermission.READ, ToolPermission.INTERACTIVE}),
|
|
53
|
+
"suggest": frozenset({ToolPermission.READ, ToolPermission.INTERACTIVE}),
|
|
54
|
+
"auto-edit": frozenset(
|
|
55
|
+
{
|
|
56
|
+
ToolPermission.READ,
|
|
57
|
+
ToolPermission.INTERACTIVE,
|
|
58
|
+
ToolPermission.WRITE,
|
|
59
|
+
ToolPermission.NETWORK,
|
|
60
|
+
}
|
|
61
|
+
),
|
|
62
|
+
"full-auto": frozenset(ToolPermission),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_FORBIDDEN: dict[ApprovalMode, frozenset[ToolPermission]] = {
|
|
66
|
+
# `plan` is a research mode: a mid-plan "may I write this file?" prompt would
|
|
67
|
+
# defeat it rather than enforce it. Network is not refused — it only mutates what
|
|
68
|
+
# the agent knows — but it asks, because egress is a disclosure to authorise.
|
|
69
|
+
"plan": frozenset({ToolPermission.WRITE, ToolPermission.EXECUTE}),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def requires_approval(mode: ApprovalMode, permission: ToolPermission) -> bool:
|
|
74
|
+
"""Whether a tool of this permission class must ask the human before running."""
|
|
75
|
+
return permission not in _UNATTENDED.get(mode, _UNATTENDED[DEFAULT_APPROVAL_MODE])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_forbidden(mode: ApprovalMode, permission: ToolPermission) -> bool:
|
|
79
|
+
"""Whether this mode refuses the tool outright, with no prompt to offer.
|
|
80
|
+
|
|
81
|
+
Distinct from `requires_approval`: "ask first" is a pause a human can resolve, while
|
|
82
|
+
this is a capability the mode does not have. The agent needs to know which it hit —
|
|
83
|
+
retrying a forbidden tool is never going to work.
|
|
84
|
+
"""
|
|
85
|
+
return permission in _FORBIDDEN.get(mode, frozenset())
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Harness context — the workspace state built-in tools share, and the `T` in `Runtime[T]`.
|
|
3
|
+
|
|
4
|
+
`Runtime` carries one call's plumbing (who, when, may-I). This carries what the
|
|
5
|
+
*session* knows: where it is rooted, what it may write to, what it has read, what it
|
|
6
|
+
plans next. The split is what lets another agent define its own context and reuse the
|
|
7
|
+
same `@tool` machinery.
|
|
8
|
+
|
|
9
|
+
Two invariants live here rather than in the tools that depend on them, because a rule
|
|
10
|
+
enforced in four places will soon be enforced in three:
|
|
11
|
+
|
|
12
|
+
**Path scope.** Two sub-agents must never write the same file. Scope is assigned at
|
|
13
|
+
spawn and checked in `ensure_writable`.
|
|
14
|
+
|
|
15
|
+
**Read before overwrite.** `write` to an existing file requires this session to have
|
|
16
|
+
read it. Clobbering a file the agent never looked at is its most expensive mistake, and
|
|
17
|
+
it cannot be detected afterwards — the old contents are simply gone.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any, Iterable, Literal
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
|
|
30
|
+
from stcode.core.configs import ToolConfig
|
|
31
|
+
from stcode.core.harness.errors import ToolError
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING: # pragma: no cover - import cycle at runtime, fine for type checkers
|
|
34
|
+
from stcode.core.harness.skills import SkillRegistry
|
|
35
|
+
from stcode.core.harness.tools.shell import BackgroundShell
|
|
36
|
+
from stcode.core.repl import PyREPL
|
|
37
|
+
|
|
38
|
+
TodoStatus = Literal["pending", "in_progress", "completed"]
|
|
39
|
+
|
|
40
|
+
DEFAULT_IGNORED_DIRS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
".git",
|
|
43
|
+
".hg",
|
|
44
|
+
".svn",
|
|
45
|
+
".venv",
|
|
46
|
+
"venv",
|
|
47
|
+
"node_modules",
|
|
48
|
+
"__pycache__",
|
|
49
|
+
".mypy_cache",
|
|
50
|
+
".pytest_cache",
|
|
51
|
+
".ruff_cache",
|
|
52
|
+
"dist",
|
|
53
|
+
"build",
|
|
54
|
+
".next",
|
|
55
|
+
"target",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
"""Skipped when no `.gitignore` is available. Not a security boundary — a deliberate
|
|
59
|
+
`read(".venv/...")` still works. It exists so `ls` and `glob` answer the question the
|
|
60
|
+
agent meant to ask."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# These field descriptions end up in a tool's JSON Schema, so they are written for the
|
|
64
|
+
# model: short, imperative, no rationale. Two phrasings because the UI shows the
|
|
65
|
+
# participle while an item runs — deriving one from the other in English does not
|
|
66
|
+
# survive contact with real task names.
|
|
67
|
+
class TodoItem(BaseModel):
|
|
68
|
+
"""A single task in the plan."""
|
|
69
|
+
|
|
70
|
+
content: str = Field(description="The task, in the imperative: 'Add the retry test'.")
|
|
71
|
+
status: TodoStatus = Field(default="pending", description="Where this task stands.")
|
|
72
|
+
active_form: str = Field(
|
|
73
|
+
default="",
|
|
74
|
+
description="The same task in the present participle, shown while it runs: "
|
|
75
|
+
"'Adding the retry test'.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def render(self) -> str:
|
|
79
|
+
mark = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]"}[self.status]
|
|
80
|
+
return f"{mark} {self.content}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class HarnessContext:
|
|
85
|
+
"""What the built-in tools know about this session's workspace."""
|
|
86
|
+
|
|
87
|
+
cwd: Path = field(default_factory=Path.cwd)
|
|
88
|
+
"""Root for every relative path a tool is given, and the default search root."""
|
|
89
|
+
|
|
90
|
+
scope: tuple[Path, ...] = ()
|
|
91
|
+
"""Paths this agent may write to. Empty means all of `cwd` — the main agent's
|
|
92
|
+
normal state. A sub-agent gets a narrower tuple at spawn."""
|
|
93
|
+
|
|
94
|
+
read_files: dict[Path, float] = field(default_factory=dict)
|
|
95
|
+
"""Absolute path -> mtime at last read. Feeds the read-before-overwrite check, and
|
|
96
|
+
lets `edit` notice a file that changed underneath it."""
|
|
97
|
+
|
|
98
|
+
todos: list[TodoItem] = field(default_factory=list)
|
|
99
|
+
session_ctx: dict[str, Any] = field(default_factory=dict)
|
|
100
|
+
"""Knowledge shared down into sibling sub-agents — the in-process twin of the
|
|
101
|
+
REPL's `session_ctx`."""
|
|
102
|
+
|
|
103
|
+
shells: dict[str, "BackgroundShell"] = field(default_factory=dict)
|
|
104
|
+
"""Background processes started by `bash(..., background=True)`, by shell id."""
|
|
105
|
+
|
|
106
|
+
skills: "SkillRegistry | None" = None
|
|
107
|
+
|
|
108
|
+
repl: "PyREPL | None" = None
|
|
109
|
+
"""The session's persistent Python REPL. Attached by `Harness.create` and lazy —
|
|
110
|
+
the subprocess waits for the first cell. Read by the `repl` tool and by
|
|
111
|
+
`Harness.invoke`, which pushes elided payloads into its `tool_out`.
|
|
112
|
+
|
|
113
|
+
None for a sub-agent and for `load_repl=False`. When None, the elision hint drops
|
|
114
|
+
back to "ask for a narrower range" — `tool_out` is then unreachable."""
|
|
115
|
+
|
|
116
|
+
git: str = ""
|
|
117
|
+
"""Branch, status and recent commits. Sampled once at `Harness.create` and never
|
|
118
|
+
refreshed: the system prompt is the cached prefix, and re-sampling per turn would
|
|
119
|
+
invalidate it every turn. A turn needing current state runs `git status`."""
|
|
120
|
+
|
|
121
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
122
|
+
"""Extra environment for spawned processes, layered over `os.environ`."""
|
|
123
|
+
|
|
124
|
+
tool_config: ToolConfig = field(default_factory=ToolConfig)
|
|
125
|
+
"""The complete `[tools]` configuration, including service credentials."""
|
|
126
|
+
|
|
127
|
+
# ---- paths ----
|
|
128
|
+
|
|
129
|
+
def resolve(self, path: str | Path) -> Path:
|
|
130
|
+
"""Absolute, `~`-expanded, and rooted at `cwd` when relative.
|
|
131
|
+
|
|
132
|
+
Not `Path.resolve()`: it follows symlinks, so a repo under a symlinked home
|
|
133
|
+
would fail its own scope check for paths the user legitimately typed. Only `..`
|
|
134
|
+
is normalised — the part that matters for escaping a scope.
|
|
135
|
+
"""
|
|
136
|
+
candidate = Path(os.path.expanduser(str(path)))
|
|
137
|
+
if not candidate.is_absolute():
|
|
138
|
+
candidate = self.cwd / candidate
|
|
139
|
+
return Path(os.path.normpath(candidate))
|
|
140
|
+
|
|
141
|
+
def in_scope(self, path: str | Path) -> bool:
|
|
142
|
+
target = self.resolve(path)
|
|
143
|
+
roots: Iterable[Path] = self.scope or (self.cwd,)
|
|
144
|
+
return any(target == root or root in target.parents for root in roots)
|
|
145
|
+
|
|
146
|
+
def ensure_writable(self, path: str | Path) -> Path:
|
|
147
|
+
"""Resolve `path` and refuse it if this agent has no business writing there."""
|
|
148
|
+
target = self.resolve(path)
|
|
149
|
+
if self.in_scope(target):
|
|
150
|
+
return target
|
|
151
|
+
allowed = ", ".join(str(p) for p in (self.scope or (self.cwd,)))
|
|
152
|
+
raise ToolError(
|
|
153
|
+
f"{target} is outside this agent's write scope ({allowed}). Another agent "
|
|
154
|
+
"owns that path — report what needs changing there instead of editing it."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def note_read(self, path: Path) -> None:
|
|
158
|
+
try:
|
|
159
|
+
self.read_files[path] = path.stat().st_mtime
|
|
160
|
+
except OSError:
|
|
161
|
+
self.read_files[path] = 0.0
|
|
162
|
+
|
|
163
|
+
def has_read(self, path: Path) -> bool:
|
|
164
|
+
return path in self.read_files
|
|
165
|
+
|
|
166
|
+
def changed_since_read(self, path: Path) -> bool:
|
|
167
|
+
"""Whether `path` changed since this session read it.
|
|
168
|
+
|
|
169
|
+
Guards against editing from a stale mental model — someone else's save, or a
|
|
170
|
+
sibling agent that should not have been in there at all.
|
|
171
|
+
"""
|
|
172
|
+
seen = self.read_files.get(path)
|
|
173
|
+
if seen is None:
|
|
174
|
+
return False
|
|
175
|
+
try:
|
|
176
|
+
return path.stat().st_mtime > seen
|
|
177
|
+
except OSError:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
# ---- todos ----
|
|
181
|
+
|
|
182
|
+
def render_todos(self) -> str:
|
|
183
|
+
if not self.todos:
|
|
184
|
+
return "(no todos)"
|
|
185
|
+
return "\n".join(item.render() for item in self.todos)
|
|
186
|
+
|
|
187
|
+
# ---- process environment ----
|
|
188
|
+
|
|
189
|
+
def process_env(self) -> dict[str, str]:
|
|
190
|
+
return {**os.environ, **self.env}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TodoList(BaseModel):
|
|
194
|
+
"""Wrapper so `todo_write` takes one well-named argument rather than a bare list."""
|
|
195
|
+
|
|
196
|
+
items: list[TodoItem] = Field(default_factory=list)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---- repository state ------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
GIT_TIMEOUT = 5.0
|
|
202
|
+
"""Past this, the repo is large enough or the filesystem slow enough that blocking
|
|
203
|
+
startup is the wrong trade. The prompt loses a section; `git status` still works."""
|
|
204
|
+
|
|
205
|
+
_GIT_QUERIES = (
|
|
206
|
+
("Branch", ("rev-parse", "--abbrev-ref", "HEAD")),
|
|
207
|
+
("Status", ("status", "--short", "--branch")),
|
|
208
|
+
("Recent commits", ("log", "-5", "--oneline", "--no-decorate")),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def git_context(cwd: Path) -> str:
|
|
213
|
+
"""Branch, working-tree status and the last five commits, as a prompt section.
|
|
214
|
+
|
|
215
|
+
Sampled once and never refreshed: it is the highest quality-per-line addition to
|
|
216
|
+
the prompt, and re-running it per turn would move the prompt and undo the caching.
|
|
217
|
+
|
|
218
|
+
Returns "" outside a repository or without `git`. A missing section is correct
|
|
219
|
+
here, not an error — plenty of work happens outside a checkout.
|
|
220
|
+
"""
|
|
221
|
+
async def run(arguments: tuple[str, ...]) -> str:
|
|
222
|
+
try:
|
|
223
|
+
process = await asyncio.create_subprocess_exec(
|
|
224
|
+
"git", "-C", str(cwd), *arguments,
|
|
225
|
+
stdout=asyncio.subprocess.PIPE,
|
|
226
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
227
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
228
|
+
)
|
|
229
|
+
stdout, _ = await asyncio.wait_for(process.communicate(), GIT_TIMEOUT)
|
|
230
|
+
except (OSError, asyncio.TimeoutError):
|
|
231
|
+
return ""
|
|
232
|
+
if process.returncode != 0:
|
|
233
|
+
return ""
|
|
234
|
+
return stdout.decode("utf-8", errors="replace").strip()
|
|
235
|
+
|
|
236
|
+
if not await run(("rev-parse", "--is-inside-work-tree")):
|
|
237
|
+
return ""
|
|
238
|
+
|
|
239
|
+
sections = await asyncio.gather(*(run(arguments) for _, arguments in _GIT_QUERIES))
|
|
240
|
+
lines = ["Repository:"]
|
|
241
|
+
for (label, _), output in zip(_GIT_QUERIES, sections):
|
|
242
|
+
if not output:
|
|
243
|
+
continue
|
|
244
|
+
if "\n" in output:
|
|
245
|
+
lines.append(f"{label}:")
|
|
246
|
+
lines += [f" {line}" for line in output.splitlines()[:12]]
|
|
247
|
+
else:
|
|
248
|
+
lines.append(f"{label}: {output}")
|
|
249
|
+
return "\n".join(lines) if len(lines) > 1 else ""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = [
|
|
253
|
+
"DEFAULT_IGNORED_DIRS",
|
|
254
|
+
"GIT_TIMEOUT",
|
|
255
|
+
"HarnessContext",
|
|
256
|
+
"TodoItem",
|
|
257
|
+
"TodoList",
|
|
258
|
+
"TodoStatus",
|
|
259
|
+
"git_context",
|
|
260
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool failures — the vocabulary for something going wrong.
|
|
3
|
+
|
|
4
|
+
A leaf module: `context.py` raises these and `tools/base.py` catches them, so putting
|
|
5
|
+
them in either would make a cycle. The agent loop reacts differently to each:
|
|
6
|
+
|
|
7
|
+
* `ToolError` — the tool worked, and the answer is "no, because…".
|
|
8
|
+
* `ToolDenied` — a human said no. Could go the other way next time.
|
|
9
|
+
* `ToolForbidden` — the mode lacks this capability. Retrying cannot help.
|
|
10
|
+
* `ToolCancelled` / `ToolTimeout` — did not finish. An interrupt means the user wants
|
|
11
|
+
something else; a timeout means narrow the request.
|
|
12
|
+
|
|
13
|
+
A bug *inside* a tool is none of these: logged with its traceback, summarised for the
|
|
14
|
+
model. A stack frame is not advice.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolError(Exception):
|
|
21
|
+
"""A tool failed in a way the model should read and react to."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolDenied(ToolError):
|
|
25
|
+
"""The human was asked to approve this call and said no."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolForbidden(ToolError):
|
|
29
|
+
"""The current approval mode does not have this capability at all."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolCancelled(ToolError):
|
|
33
|
+
"""The session was interrupted while this tool was running."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ToolTimeout(ToolError):
|
|
37
|
+
"""The tool exceeded its time budget."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["ToolCancelled", "ToolDenied", "ToolError", "ToolForbidden", "ToolTimeout"]
|