rrft 0.1.0a0__tar.gz
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.
- rrft-0.1.0a0/.gitignore +11 -0
- rrft-0.1.0a0/CHANGELOG.md +5 -0
- rrft-0.1.0a0/PKG-INFO +23 -0
- rrft-0.1.0a0/README.md +7 -0
- rrft-0.1.0a0/pyproject.toml +60 -0
- rrft-0.1.0a0/src/rrft/__init__.py +34 -0
- rrft-0.1.0a0/src/rrft/agent.py +55 -0
- rrft-0.1.0a0/src/rrft/boot.py +25 -0
- rrft-0.1.0a0/src/rrft/cli.py +258 -0
- rrft-0.1.0a0/src/rrft/errors.py +13 -0
- rrft-0.1.0a0/src/rrft/event_log.py +95 -0
- rrft-0.1.0a0/src/rrft/harness.py +222 -0
- rrft-0.1.0a0/src/rrft/path_jail.py +37 -0
- rrft-0.1.0a0/src/rrft/protocol.py +39 -0
- rrft-0.1.0a0/src/rrft/scopes.py +37 -0
- rrft-0.1.0a0/src/rrft/tools/__init__.py +0 -0
- rrft-0.1.0a0/src/rrft/tools/base.py +41 -0
- rrft-0.1.0a0/src/rrft/tools/grep.py +98 -0
- rrft-0.1.0a0/src/rrft/tools/ls.py +51 -0
- rrft-0.1.0a0/src/rrft/tools/read.py +61 -0
- rrft-0.1.0a0/src/rrft/tools/skill.py +32 -0
- rrft-0.1.0a0/tests/__init__.py +0 -0
- rrft-0.1.0a0/tests/_mock.py +32 -0
- rrft-0.1.0a0/tests/conftest.py +21 -0
- rrft-0.1.0a0/tests/fixtures/agents/minimal/AGENT.md +8 -0
- rrft-0.1.0a0/tests/fixtures/agents/minimal/INDEX.md +6 -0
- rrft-0.1.0a0/tests/fixtures/agents/minimal/instructions/_index.md +3 -0
- rrft-0.1.0a0/tests/fixtures/agents/minimal/knowledge/_index.md +3 -0
- rrft-0.1.0a0/tests/fixtures/agents/minimal/skills/_index.md +3 -0
- rrft-0.1.0a0/tests/test_agent.py +64 -0
- rrft-0.1.0a0/tests/test_agent_turn_e2e.py +70 -0
- rrft-0.1.0a0/tests/test_boot.py +55 -0
- rrft-0.1.0a0/tests/test_build_history.py +61 -0
- rrft-0.1.0a0/tests/test_cli.py +99 -0
- rrft-0.1.0a0/tests/test_errors.py +15 -0
- rrft-0.1.0a0/tests/test_event_log.py +144 -0
- rrft-0.1.0a0/tests/test_harness.py +203 -0
- rrft-0.1.0a0/tests/test_harness_resume.py +154 -0
- rrft-0.1.0a0/tests/test_path_jail.py +91 -0
- rrft-0.1.0a0/tests/test_public_api.py +23 -0
- rrft-0.1.0a0/tests/test_scopes.py +53 -0
- rrft-0.1.0a0/tests/tools/__init__.py +0 -0
- rrft-0.1.0a0/tests/tools/test_base.py +34 -0
- rrft-0.1.0a0/tests/tools/test_grep.py +74 -0
- rrft-0.1.0a0/tests/tools/test_ls.py +62 -0
- rrft-0.1.0a0/tests/tools/test_read.py +63 -0
- rrft-0.1.0a0/tests/tools/test_skill.py +39 -0
- rrft-0.1.0a0/uv.lock +442 -0
rrft-0.1.0a0/.gitignore
ADDED
rrft-0.1.0a0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rrft
|
|
3
|
+
Version: 0.1.0a0
|
|
4
|
+
Summary: rrft - file tree agent engine
|
|
5
|
+
Author-email: 0x0064 <user.frndvrgs@gmail.com>
|
|
6
|
+
Keywords: agent,ai,assistant,file,python,rrft,sdk,tree
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: pydantic>=2.6
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: hypothesis>=6.100; extra == 'dev'
|
|
11
|
+
Requires-Dist: mypy>=1.20; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# rrft
|
|
18
|
+
|
|
19
|
+
rrft - file tree agent engine
|
|
20
|
+
|
|
21
|
+
## License
|
|
22
|
+
|
|
23
|
+
MIT (pending).
|
rrft-0.1.0a0/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rrft"
|
|
3
|
+
version = "0.1.0a0"
|
|
4
|
+
description = "rrft - file tree agent engine"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
authors = [
|
|
8
|
+
{name = "0x0064", email = "user.frndvrgs@gmail.com"},
|
|
9
|
+
]
|
|
10
|
+
keywords = [
|
|
11
|
+
"rrft",
|
|
12
|
+
"file",
|
|
13
|
+
"tree",
|
|
14
|
+
"assistant",
|
|
15
|
+
"agent",
|
|
16
|
+
"sdk",
|
|
17
|
+
"ai",
|
|
18
|
+
"python"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
dependencies = [
|
|
22
|
+
"pydantic>=2.6",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8.0",
|
|
28
|
+
"pytest-asyncio>=0.23",
|
|
29
|
+
"hypothesis>=6.100",
|
|
30
|
+
"ruff>=0.5",
|
|
31
|
+
"mypy>=1.20",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
rrft = "rrft.cli:main"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/rrft"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
pythonpath = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
target-version = "py311"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "W", "B", "UP", "SIM"]
|
|
55
|
+
ignore = ["E501"]
|
|
56
|
+
|
|
57
|
+
[dependency-groups]
|
|
58
|
+
dev = [
|
|
59
|
+
"mypy>=1.20.0",
|
|
60
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rrft.agent import Agent
|
|
4
|
+
from rrft.errors import BaseError, ProviderError, ScopeError
|
|
5
|
+
from rrft.protocol import (
|
|
6
|
+
Message,
|
|
7
|
+
Provider,
|
|
8
|
+
ProviderReply,
|
|
9
|
+
ToolCall,
|
|
10
|
+
ToolSpec,
|
|
11
|
+
)
|
|
12
|
+
from rrft.tools.grep import GrepTool
|
|
13
|
+
from rrft.tools.ls import LSTool
|
|
14
|
+
from rrft.tools.read import ReadTool
|
|
15
|
+
from rrft.tools.skill import SkillTool
|
|
16
|
+
|
|
17
|
+
__version__ = "0.0.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Agent",
|
|
21
|
+
"Message",
|
|
22
|
+
"ToolCall",
|
|
23
|
+
"ToolSpec",
|
|
24
|
+
"GrepTool",
|
|
25
|
+
"LSTool",
|
|
26
|
+
"Provider",
|
|
27
|
+
"ProviderError",
|
|
28
|
+
"ProviderReply",
|
|
29
|
+
"ReadTool",
|
|
30
|
+
"ScopeError",
|
|
31
|
+
"SkillTool",
|
|
32
|
+
"BaseError",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
8
|
+
|
|
9
|
+
from rrft.harness import Harness
|
|
10
|
+
from rrft.scopes import build_scope
|
|
11
|
+
from rrft.tools.base import Tool
|
|
12
|
+
from rrft.tools.grep import GrepTool
|
|
13
|
+
from rrft.tools.ls import LSTool
|
|
14
|
+
from rrft.tools.read import ReadTool
|
|
15
|
+
from rrft.tools.skill import SkillTool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_tools() -> list[Tool]:
|
|
19
|
+
return [ReadTool(), GrepTool(), LSTool(), SkillTool()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Agent(BaseModel):
|
|
23
|
+
root: Path
|
|
24
|
+
provider: Any
|
|
25
|
+
namespaces: list[str] = Field(default_factory=list)
|
|
26
|
+
tools: list[Tool] = Field(default_factory=_default_tools)
|
|
27
|
+
|
|
28
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
29
|
+
|
|
30
|
+
_harness: Harness = PrivateAttr()
|
|
31
|
+
_locks: dict[str, asyncio.Lock] = PrivateAttr(default_factory=dict)
|
|
32
|
+
_locks_guard: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
|
|
33
|
+
|
|
34
|
+
def model_post_init(self, __context: object) -> None:
|
|
35
|
+
self._harness = Harness(agent_root=self.root, provider=self.provider, tools=self.tools)
|
|
36
|
+
|
|
37
|
+
async def _lock_for(self, key: str) -> asyncio.Lock:
|
|
38
|
+
async with self._locks_guard:
|
|
39
|
+
lock = self._locks.get(key)
|
|
40
|
+
if lock is None:
|
|
41
|
+
lock = asyncio.Lock()
|
|
42
|
+
self._locks[key] = lock
|
|
43
|
+
return lock
|
|
44
|
+
|
|
45
|
+
async def turn(self, session_id: str, message: str, scope: dict[str, str]) -> str:
|
|
46
|
+
parsed = build_scope(self.namespaces, scope)
|
|
47
|
+
lock = await self._lock_for(f"{parsed.leaf}::{session_id}")
|
|
48
|
+
async with lock:
|
|
49
|
+
return await self._harness.turn(scope=parsed, session_id=session_id, message=message)
|
|
50
|
+
|
|
51
|
+
async def resume(self, session_id: str, scope: dict[str, str]) -> str:
|
|
52
|
+
parsed = build_scope(self.namespaces, scope)
|
|
53
|
+
lock = await self._lock_for(f"{parsed.leaf}::{session_id}")
|
|
54
|
+
async with lock:
|
|
55
|
+
return await self._harness.resume(scope=parsed, session_id=session_id)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rrft.scopes import Scope
|
|
6
|
+
|
|
7
|
+
_LABELS = ["AGENT", "INDEX", "INSTRUCTIONS", "SKILLS", "KNOWLEDGE", "MEMORY", "SESSIONS"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_boot_bundle(agent_root: Path, scope: Scope) -> str:
|
|
11
|
+
scope_dir = agent_root / "data" / scope.leaf
|
|
12
|
+
sources = [
|
|
13
|
+
agent_root / "AGENT.md",
|
|
14
|
+
agent_root / "INDEX.md",
|
|
15
|
+
agent_root / "instructions" / "_index.md",
|
|
16
|
+
agent_root / "skills" / "_index.md",
|
|
17
|
+
agent_root / "knowledge" / "_index.md",
|
|
18
|
+
scope_dir / "memory" / "MEMORY.md",
|
|
19
|
+
scope_dir / "sessions" / "_index.md",
|
|
20
|
+
]
|
|
21
|
+
sections: list[str] = []
|
|
22
|
+
for label, path in zip(_LABELS, sources, strict=True):
|
|
23
|
+
if path.exists():
|
|
24
|
+
sections.append(f"<<<{label}>>>\n{path.read_text(encoding='utf-8')}")
|
|
25
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rrft import __version__
|
|
9
|
+
from rrft.boot import build_boot_bundle
|
|
10
|
+
from rrft.path_jail import PathJail
|
|
11
|
+
from rrft.scopes import build_scope
|
|
12
|
+
from rrft.tools.base import Tool, ToolContext, ToolResult
|
|
13
|
+
from rrft.tools.grep import GrepInput, GrepTool
|
|
14
|
+
from rrft.tools.ls import LSInput, LSTool
|
|
15
|
+
from rrft.tools.read import ReadInput, ReadTool
|
|
16
|
+
from rrft.tools.skill import SkillInput, SkillTool
|
|
17
|
+
|
|
18
|
+
_AGENT_TEMPLATE = """\
|
|
19
|
+
---
|
|
20
|
+
name: my-agent
|
|
21
|
+
persona: write your agent's persona here
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# My Agent
|
|
25
|
+
|
|
26
|
+
You are a [describe what this agent does].
|
|
27
|
+
|
|
28
|
+
## How you work
|
|
29
|
+
|
|
30
|
+
- Read `INDEX.md` first to orient yourself in the filetree.
|
|
31
|
+
- Use `Grep` to find relevant passages in `knowledge/`.
|
|
32
|
+
- Use `Read` to pull the full text of a document when needed.
|
|
33
|
+
- Use `Skill` when the user's request matches a named procedure in `skills/`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
_INDEX_TEMPLATE = """\
|
|
37
|
+
# Map
|
|
38
|
+
|
|
39
|
+
- `AGENT.md` — who you are, how you work
|
|
40
|
+
- `instructions/` — behavioral rules (tone, escalation, refusal policy)
|
|
41
|
+
- `skills/` — triggerable procedures, one folder per skill
|
|
42
|
+
- `knowledge/` — reference material, organized by topic
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_SUB_INDEX_TEMPLATES = {
|
|
46
|
+
"instructions": "# Instructions\n\n(empty — add behavioral docs here)\n",
|
|
47
|
+
"skills": "# Skills\n\n(empty — add skills as folders containing `SKILL.md`)\n",
|
|
48
|
+
"knowledge": "# Knowledge\n\n(empty — add reference material here)\n",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_REQUIRED_FILES = (
|
|
52
|
+
"AGENT.md",
|
|
53
|
+
"INDEX.md",
|
|
54
|
+
"instructions/_index.md",
|
|
55
|
+
"skills/_index.md",
|
|
56
|
+
"knowledge/_index.md",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str] | None = None) -> int:
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="rrft",
|
|
63
|
+
description="Filesystem-backed agent SDK — direct tool access for debugging and scaffolding.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("--version", action="version", version=f"rrft {__version__}")
|
|
66
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
67
|
+
|
|
68
|
+
p_init = sub.add_parser("init", help="scaffold a new agent tree")
|
|
69
|
+
p_init.add_argument("path", help="directory to create")
|
|
70
|
+
|
|
71
|
+
p_inspect = sub.add_parser("inspect", help="validate an agent tree and show its shape")
|
|
72
|
+
p_inspect.add_argument("root", help="path to an agent tree")
|
|
73
|
+
|
|
74
|
+
p_boot = sub.add_parser("boot", help="print the boot bundle for a scope")
|
|
75
|
+
p_boot.add_argument("root")
|
|
76
|
+
p_boot.add_argument(
|
|
77
|
+
"--namespace",
|
|
78
|
+
action="append",
|
|
79
|
+
default=[],
|
|
80
|
+
metavar="KEY",
|
|
81
|
+
help="declared namespace key (repeatable, order matters)",
|
|
82
|
+
)
|
|
83
|
+
p_boot.add_argument(
|
|
84
|
+
"--scope",
|
|
85
|
+
action="append",
|
|
86
|
+
default=[],
|
|
87
|
+
metavar="KEY=VALUE",
|
|
88
|
+
help="scope value (repeatable)",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
p_read = sub.add_parser("read", help="run the Read tool directly")
|
|
92
|
+
p_read.add_argument("root")
|
|
93
|
+
p_read.add_argument("file", help="file path inside the agent tree")
|
|
94
|
+
p_read.add_argument("--offset", type=int, default=0)
|
|
95
|
+
p_read.add_argument("--limit", type=int, default=None)
|
|
96
|
+
|
|
97
|
+
p_grep = sub.add_parser("grep", help="run the Grep tool directly (ripgrep-backed)")
|
|
98
|
+
p_grep.add_argument("root")
|
|
99
|
+
p_grep.add_argument("pattern")
|
|
100
|
+
p_grep.add_argument("--path", default=None, help="restrict to this subtree")
|
|
101
|
+
p_grep.add_argument("--glob", default=None, help="filename filter, e.g. '*.md'")
|
|
102
|
+
p_grep.add_argument("-i", "--case-insensitive", action="store_true")
|
|
103
|
+
p_grep.add_argument("--mode", choices=["files", "content"], default="files")
|
|
104
|
+
p_grep.add_argument("--limit", type=int, default=250)
|
|
105
|
+
|
|
106
|
+
p_ls = sub.add_parser("ls", help="run the LS tool directly")
|
|
107
|
+
p_ls.add_argument("root")
|
|
108
|
+
p_ls.add_argument("--path", default=None)
|
|
109
|
+
p_ls.add_argument("--limit", type=int, default=250)
|
|
110
|
+
|
|
111
|
+
p_skill = sub.add_parser("skill", help="load a skill body by name")
|
|
112
|
+
p_skill.add_argument("root")
|
|
113
|
+
p_skill.add_argument("name", help="skill folder name under skills/")
|
|
114
|
+
|
|
115
|
+
args = parser.parse_args(argv)
|
|
116
|
+
handlers = {
|
|
117
|
+
"init": _init,
|
|
118
|
+
"inspect": _inspect,
|
|
119
|
+
"boot": _boot,
|
|
120
|
+
"read": _read,
|
|
121
|
+
"grep": _grep,
|
|
122
|
+
"ls": _ls,
|
|
123
|
+
"skill": _skill,
|
|
124
|
+
}
|
|
125
|
+
return handlers[args.cmd](args)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _init(args: argparse.Namespace) -> int:
|
|
129
|
+
root = Path(args.path)
|
|
130
|
+
if root.exists() and any(root.iterdir()):
|
|
131
|
+
print(f"error: {root} exists and is not empty", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
(root / "AGENT.md").write_text(_AGENT_TEMPLATE)
|
|
135
|
+
(root / "INDEX.md").write_text(_INDEX_TEMPLATE)
|
|
136
|
+
for name, template in _SUB_INDEX_TEMPLATES.items():
|
|
137
|
+
folder = root / name
|
|
138
|
+
folder.mkdir()
|
|
139
|
+
(folder / "_index.md").write_text(template)
|
|
140
|
+
print(f"scaffolded agent at {root.resolve()}")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _inspect(args: argparse.Namespace) -> int:
|
|
145
|
+
root = Path(args.root)
|
|
146
|
+
if not root.exists():
|
|
147
|
+
print(f"error: not found: {root}", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
missing = [rel for rel in _REQUIRED_FILES if not (root / rel).exists()]
|
|
150
|
+
if missing:
|
|
151
|
+
print(f"invalid agent tree at {root}: missing", file=sys.stderr)
|
|
152
|
+
for rel in missing:
|
|
153
|
+
print(f" {rel}", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
print(f"valid agent tree: {root.resolve()}")
|
|
156
|
+
for entry in sorted(root.iterdir()):
|
|
157
|
+
if entry.name.startswith("."):
|
|
158
|
+
continue
|
|
159
|
+
if entry.is_dir():
|
|
160
|
+
md_count = sum(1 for _ in entry.rglob("*.md"))
|
|
161
|
+
print(f" {entry.name}/ ({md_count} .md files)")
|
|
162
|
+
else:
|
|
163
|
+
print(f" {entry.name}")
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _boot(args: argparse.Namespace) -> int:
|
|
168
|
+
root = Path(args.root)
|
|
169
|
+
scope_values = _parse_scope(args.scope)
|
|
170
|
+
scope = build_scope(args.namespace, scope_values)
|
|
171
|
+
print(build_boot_bundle(root, scope))
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read(args: argparse.Namespace) -> int:
|
|
176
|
+
return asyncio.run(
|
|
177
|
+
_run_tool(
|
|
178
|
+
Path(args.root),
|
|
179
|
+
ReadTool(),
|
|
180
|
+
ReadInput(path=args.file, offset=args.offset, limit=args.limit),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _grep(args: argparse.Namespace) -> int:
|
|
186
|
+
return asyncio.run(
|
|
187
|
+
_run_tool(
|
|
188
|
+
Path(args.root),
|
|
189
|
+
GrepTool(),
|
|
190
|
+
GrepInput(
|
|
191
|
+
pattern=args.pattern,
|
|
192
|
+
path=args.path,
|
|
193
|
+
glob=args.glob,
|
|
194
|
+
case_insensitive=args.case_insensitive,
|
|
195
|
+
mode=args.mode,
|
|
196
|
+
head_limit=args.limit,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ls(args: argparse.Namespace) -> int:
|
|
203
|
+
return asyncio.run(
|
|
204
|
+
_run_tool(
|
|
205
|
+
Path(args.root),
|
|
206
|
+
LSTool(),
|
|
207
|
+
LSInput(path=args.path, limit=args.limit),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _skill(args: argparse.Namespace) -> int:
|
|
213
|
+
return asyncio.run(_run_tool(Path(args.root), SkillTool(), SkillInput(name=args.name)))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _run_tool(root: Path, tool: Tool, input_obj) -> int: # type: ignore[type-arg]
|
|
217
|
+
context = ToolContext(
|
|
218
|
+
path_jail=PathJail(
|
|
219
|
+
read_roots=[
|
|
220
|
+
root / "AGENT.md",
|
|
221
|
+
root / "INDEX.md",
|
|
222
|
+
root / "instructions",
|
|
223
|
+
root / "skills",
|
|
224
|
+
root / "knowledge",
|
|
225
|
+
],
|
|
226
|
+
write_roots=[],
|
|
227
|
+
),
|
|
228
|
+
agent_root=root.resolve(),
|
|
229
|
+
cancel=asyncio.Event(),
|
|
230
|
+
)
|
|
231
|
+
result: ToolResult = await tool.call(input_obj, context)
|
|
232
|
+
return _emit(result)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _emit(result: ToolResult) -> int:
|
|
236
|
+
if result.is_error:
|
|
237
|
+
print(result.content, file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
print(result.content)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_scope(items: list[str]) -> dict[str, str]:
|
|
244
|
+
out: dict[str, str] = {}
|
|
245
|
+
for item in items:
|
|
246
|
+
if "=" not in item:
|
|
247
|
+
print(
|
|
248
|
+
f"error: invalid --scope '{item}' (expected KEY=VALUE)",
|
|
249
|
+
file=sys.stderr,
|
|
250
|
+
)
|
|
251
|
+
sys.exit(2)
|
|
252
|
+
key, value = item.split("=", 1)
|
|
253
|
+
out[key] = value
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
sys.exit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections import deque
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path as _Path
|
|
8
|
+
from typing import Annotated, Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Discriminator, Field, PrivateAttr, TypeAdapter, ValidationError
|
|
11
|
+
|
|
12
|
+
from rrft.errors import BaseError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _EventBase(BaseModel):
|
|
16
|
+
at: datetime
|
|
17
|
+
turn: int
|
|
18
|
+
|
|
19
|
+
model_config = {"frozen": True}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MessageEvent(_EventBase):
|
|
23
|
+
type: Literal["message"] = "message"
|
|
24
|
+
role: Literal["user", "assistant", "system"]
|
|
25
|
+
content: str
|
|
26
|
+
user_id: str | None = None
|
|
27
|
+
usage: dict[str, int] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolCallEvent(_EventBase):
|
|
31
|
+
type: Literal["tool_call"] = "tool_call"
|
|
32
|
+
role: Literal["assistant"] = "assistant"
|
|
33
|
+
id: str
|
|
34
|
+
tool: str
|
|
35
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolResultEvent(_EventBase):
|
|
39
|
+
type: Literal["tool_result"] = "tool_result"
|
|
40
|
+
role: Literal["tool"] = "tool"
|
|
41
|
+
id: str
|
|
42
|
+
is_error: bool
|
|
43
|
+
content: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Event = Annotated[
|
|
47
|
+
MessageEvent | ToolCallEvent | ToolResultEvent,
|
|
48
|
+
Discriminator("type"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
_ADAPTER: TypeAdapter[Event] = TypeAdapter(Event)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def dump_event(event: Event) -> str:
|
|
55
|
+
return (
|
|
56
|
+
_ADAPTER.dump_json(event, warnings="error")
|
|
57
|
+
.decode("utf-8")
|
|
58
|
+
.replace("\u2028", "\\u2028")
|
|
59
|
+
.replace("\u2029", "\\u2029")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_event(line: str) -> Event:
|
|
64
|
+
try:
|
|
65
|
+
return _ADAPTER.validate_json(line)
|
|
66
|
+
except (json.JSONDecodeError, ValidationError) as exc:
|
|
67
|
+
raise BaseError(f"malformed event: {line[:100]}") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class EventLog(BaseModel):
|
|
71
|
+
path: _Path
|
|
72
|
+
_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
|
|
73
|
+
|
|
74
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
75
|
+
|
|
76
|
+
async def append(self, event: Event) -> None:
|
|
77
|
+
async with self._lock:
|
|
78
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
80
|
+
f.write(dump_event(event) + "\n")
|
|
81
|
+
|
|
82
|
+
async def tail(self, n: int) -> list[Event]:
|
|
83
|
+
if not self.path.exists():
|
|
84
|
+
return []
|
|
85
|
+
window: deque[str] = deque(maxlen=n)
|
|
86
|
+
with self.path.open("r", encoding="utf-8") as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if line:
|
|
90
|
+
window.append(line)
|
|
91
|
+
return [load_event(line) for line in window]
|
|
92
|
+
|
|
93
|
+
async def last(self) -> Event | None:
|
|
94
|
+
tail = await self.tail(1)
|
|
95
|
+
return tail[0] if tail else None
|