@agxnte/reidx 2.0.2
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.
- package/README.md +724 -0
- package/bin/reidx.js +33 -0
- package/package.json +40 -0
- package/pyproject.toml +45 -0
- package/scripts/find-python.js +24 -0
- package/scripts/postinstall.js +35 -0
- package/src/reidx/__init__.py +3 -0
- package/src/reidx/__main__.py +9 -0
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__init__.py +3 -0
- package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/app/commands.py +221 -0
- package/src/reidx/automation/__init__.py +3 -0
- package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
- package/src/reidx/automation/exec.py +35 -0
- package/src/reidx/config/__init__.py +10 -0
- package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
- package/src/reidx/config/loader.py +104 -0
- package/src/reidx/config/models.py +49 -0
- package/src/reidx/config/settings.py +127 -0
- package/src/reidx/deepreid/__init__.py +8 -0
- package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/pipeline.py +221 -0
- package/src/reidx/diagnostics/__init__.py +0 -0
- package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/logger.py +56 -0
- package/src/reidx/goals/__init__.py +21 -0
- package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/goals/models.py +101 -0
- package/src/reidx/goals/store.py +233 -0
- package/src/reidx/integrations/__init__.py +3 -0
- package/src/reidx/integrations/mcp.py +70 -0
- package/src/reidx/nyx/__init__.py +3 -0
- package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
- package/src/reidx/nyx/persona.py +35 -0
- package/src/reidx/policy/__init__.py +15 -0
- package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/policy/engine.py +101 -0
- package/src/reidx/policy/models.py +35 -0
- package/src/reidx/provider/__init__.py +16 -0
- package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +29 -0
- package/src/reidx/provider/anthropic.py +176 -0
- package/src/reidx/provider/base.py +50 -0
- package/src/reidx/provider/ollama.py +109 -0
- package/src/reidx/provider/openai.py +144 -0
- package/src/reidx/provider/registry.py +88 -0
- package/src/reidx/provider/store.py +141 -0
- package/src/reidx/provider/stub.py +60 -0
- package/src/reidx/provider_manager/__init__.py +40 -0
- package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/catalog.py +330 -0
- package/src/reidx/provider_manager/database.py +234 -0
- package/src/reidx/provider_manager/keychain.py +102 -0
- package/src/reidx/provider_manager/palette.py +831 -0
- package/src/reidx/runtime/__init__.py +5 -0
- package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/agent.py +198 -0
- package/src/reidx/runtime/orchestrator.py +244 -0
- package/src/reidx/runtime/reasoning.py +77 -0
- package/src/reidx/runtime/state.py +32 -0
- package/src/reidx/runtime/subagent.py +174 -0
- package/src/reidx/session/__init__.py +4 -0
- package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/session/models.py +43 -0
- package/src/reidx/session/store.py +101 -0
- package/src/reidx/tasks/__init__.py +4 -0
- package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/tasks/models.py +44 -0
- package/src/reidx/tasks/store.py +86 -0
- package/src/reidx/tools/__init__.py +44 -0
- package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/base.py +84 -0
- package/src/reidx/tools/file_tools.py +222 -0
- package/src/reidx/tools/registry.py +45 -0
- package/src/reidx/tools/shell_tool.py +75 -0
- package/src/reidx/tools/spawn_agent.py +185 -0
- package/src/reidx/tools/web_tools.py +216 -0
- package/src/reidx/ui/__init__.py +41 -0
- package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
- package/src/reidx/ui/app.py +1343 -0
- package/src/reidx/ui/commands.py +663 -0
- package/src/reidx/ui/render.py +517 -0
- package/src/reidx/ui/repl.py +14 -0
- package/src/reidx/ui/theme.py +126 -0
- package/src/reidx/workflows/__init__.py +4 -0
- package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/workflows/models.py +19 -0
- package/src/reidx/workflows/store.py +56 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Subagent runtime: lifecycle tracking + event bus for spawned child agents.
|
|
2
|
+
|
|
3
|
+
The `spawn_agent` tool constructs child `Agent`s (each with its own scoped
|
|
4
|
+
`PolicyEngine` and tool allowlist) and reports their lifecycle here. The TUI
|
|
5
|
+
subscribes to the manager so the "active agents" panel below the status line
|
|
6
|
+
knows what to render — one row per live subagent, auto-hidden when none.
|
|
7
|
+
|
|
8
|
+
Threading: tool calls run in an executor thread (see ui/app.py) so
|
|
9
|
+
SubagentManager mutates from worker threads. Every mutation grabs a lock and
|
|
10
|
+
listeners are called with a snapshot copy so the UI can iterate without
|
|
11
|
+
holding it.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
SubagentStatus = Literal["running", "done", "error"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SubagentSnapshot:
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
status: SubagentStatus
|
|
30
|
+
started_at: float
|
|
31
|
+
last_action: str = ""
|
|
32
|
+
finished_at: float | None = None
|
|
33
|
+
error: str = ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def elapsed_seconds(self) -> float:
|
|
37
|
+
end = self.finished_at if self.finished_at is not None else time.monotonic()
|
|
38
|
+
return end - self.started_at
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class _Row:
|
|
43
|
+
id: str
|
|
44
|
+
name: str
|
|
45
|
+
status: SubagentStatus
|
|
46
|
+
started_at: float
|
|
47
|
+
last_action: str = ""
|
|
48
|
+
finished_at: float | None = None
|
|
49
|
+
error: str = ""
|
|
50
|
+
# Small display grace period after finish so completed agents flash
|
|
51
|
+
# briefly before disappearing (see prune_finished).
|
|
52
|
+
linger_until: float = field(default=0.0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Listener = Callable[[list[SubagentSnapshot]], None]
|
|
56
|
+
|
|
57
|
+
LINGER_SECONDS = 2.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SubagentManager:
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._rows: dict[str, _Row] = {}
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
self._listeners: list[Listener] = []
|
|
65
|
+
|
|
66
|
+
# --- subscribe --------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def subscribe(self, listener: Listener) -> Callable[[], None]:
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._listeners.append(listener)
|
|
71
|
+
|
|
72
|
+
def _unsub() -> None:
|
|
73
|
+
with self._lock:
|
|
74
|
+
if listener in self._listeners:
|
|
75
|
+
self._listeners.remove(listener)
|
|
76
|
+
return _unsub
|
|
77
|
+
|
|
78
|
+
def _notify(self) -> None:
|
|
79
|
+
# Snapshot outside the lock so listener callbacks don't reenter it.
|
|
80
|
+
snap = self.snapshot()
|
|
81
|
+
for listener in list(self._listeners):
|
|
82
|
+
try:
|
|
83
|
+
listener(snap)
|
|
84
|
+
except Exception: # noqa: BLE001 - a broken listener must not kill agents
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# --- lifecycle --------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def start(self, name: str) -> str:
|
|
90
|
+
agent_id = uuid.uuid4().hex[:8]
|
|
91
|
+
with self._lock:
|
|
92
|
+
self._rows[agent_id] = _Row(
|
|
93
|
+
id=agent_id, name=name, status="running", started_at=time.monotonic()
|
|
94
|
+
)
|
|
95
|
+
self._notify()
|
|
96
|
+
return agent_id
|
|
97
|
+
|
|
98
|
+
def update(self, agent_id: str, *, last_action: str) -> None:
|
|
99
|
+
with self._lock:
|
|
100
|
+
row = self._rows.get(agent_id)
|
|
101
|
+
if row is None or row.status != "running":
|
|
102
|
+
return
|
|
103
|
+
row.last_action = last_action
|
|
104
|
+
self._notify()
|
|
105
|
+
|
|
106
|
+
def finish(self, agent_id: str, *, status: SubagentStatus = "done", error: str = "") -> None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
row = self._rows.get(agent_id)
|
|
109
|
+
if row is None:
|
|
110
|
+
return
|
|
111
|
+
row.status = status
|
|
112
|
+
row.finished_at = time.monotonic()
|
|
113
|
+
row.error = error
|
|
114
|
+
row.linger_until = row.finished_at + LINGER_SECONDS
|
|
115
|
+
self._notify()
|
|
116
|
+
|
|
117
|
+
# --- read -------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def snapshot(self) -> list[SubagentSnapshot]:
|
|
120
|
+
with self._lock:
|
|
121
|
+
return [
|
|
122
|
+
SubagentSnapshot(
|
|
123
|
+
id=r.id, name=r.name, status=r.status, started_at=r.started_at,
|
|
124
|
+
last_action=r.last_action, finished_at=r.finished_at, error=r.error,
|
|
125
|
+
)
|
|
126
|
+
for r in self._rows.values()
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
def visible_rows(self) -> list[SubagentSnapshot]:
|
|
130
|
+
"""Rows to actually render — running + recently-finished (linger)."""
|
|
131
|
+
now = time.monotonic()
|
|
132
|
+
with self._lock:
|
|
133
|
+
keep: list[_Row] = []
|
|
134
|
+
for row in self._rows.values():
|
|
135
|
+
if row.status == "running" or row.linger_until > now:
|
|
136
|
+
keep.append(row)
|
|
137
|
+
return [
|
|
138
|
+
SubagentSnapshot(
|
|
139
|
+
id=r.id, name=r.name, status=r.status, started_at=r.started_at,
|
|
140
|
+
last_action=r.last_action, finished_at=r.finished_at, error=r.error,
|
|
141
|
+
)
|
|
142
|
+
for r in keep
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
def prune_finished(self) -> bool:
|
|
146
|
+
"""Drop finished rows past their linger window. Returns True if any
|
|
147
|
+
row was removed (so the TUI knows to invalidate and re-render). Called
|
|
148
|
+
from the spinner tick."""
|
|
149
|
+
now = time.monotonic()
|
|
150
|
+
removed = False
|
|
151
|
+
with self._lock:
|
|
152
|
+
for aid, row in list(self._rows.items()):
|
|
153
|
+
if row.status != "running" and row.linger_until <= now:
|
|
154
|
+
del self._rows[aid]
|
|
155
|
+
removed = True
|
|
156
|
+
if removed:
|
|
157
|
+
self._notify()
|
|
158
|
+
return removed
|
|
159
|
+
|
|
160
|
+
def any_active(self) -> bool:
|
|
161
|
+
with self._lock:
|
|
162
|
+
return any(r.status == "running" for r in self._rows.values())
|
|
163
|
+
|
|
164
|
+
# Kept for tests / debugging.
|
|
165
|
+
def _clear(self) -> None:
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._rows.clear()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# For type hints elsewhere without a heavy import.
|
|
171
|
+
__all__ = ["SubagentManager", "SubagentSnapshot", "LINGER_SECONDS"]
|
|
172
|
+
|
|
173
|
+
# Discourage silent replace() calls on the frozen dataclass elsewhere.
|
|
174
|
+
_ = replace # noqa: F841
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Session model.
|
|
2
|
+
|
|
3
|
+
A Session is a first-class runtime object: identity, workspace, provider/model state,
|
|
4
|
+
permission mode, status, and timestamps. Transcript and task state live alongside
|
|
5
|
+
under the session storage directory (see session.store).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from reidx.policy.models import PermissionMode
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _new_id() -> str:
|
|
19
|
+
import uuid
|
|
20
|
+
|
|
21
|
+
return uuid.uuid4().hex[:12]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionStatus(StrEnum):
|
|
25
|
+
ACTIVE = "active"
|
|
26
|
+
PAUSED = "paused"
|
|
27
|
+
ARCHIVED = "archived"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Session(BaseModel):
|
|
31
|
+
id: str = Field(default_factory=_new_id)
|
|
32
|
+
title: str = ""
|
|
33
|
+
workspace: Path
|
|
34
|
+
provider: str = "stub"
|
|
35
|
+
model: str = "stub-v0"
|
|
36
|
+
reasoning_effort: str = "medium"
|
|
37
|
+
permission_mode: PermissionMode = PermissionMode.BALANCED
|
|
38
|
+
status: SessionStatus = SessionStatus.ACTIVE
|
|
39
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
40
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
41
|
+
|
|
42
|
+
def touch(self) -> None:
|
|
43
|
+
self.updated_at = datetime.now(UTC)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Session store: structured per-session persistence.
|
|
2
|
+
|
|
3
|
+
Layout under storage_root:
|
|
4
|
+
sessions/
|
|
5
|
+
<session-id>/
|
|
6
|
+
meta.json # Session record
|
|
7
|
+
transcript.jsonl # one Message per line (restorable into state.messages)
|
|
8
|
+
tasks.json # task state for the session
|
|
9
|
+
events.jsonl # runtime action log (turn summaries, lifecycle events)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from reidx.diagnostics.logger import EventLog, get_logger
|
|
16
|
+
from reidx.provider.base import Message
|
|
17
|
+
from reidx.session.models import Session, SessionStatus
|
|
18
|
+
|
|
19
|
+
log = get_logger("reidx.session")
|
|
20
|
+
|
|
21
|
+
_MAX_RESUME_MESSAGES = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionStore:
|
|
25
|
+
def __init__(self, storage_root: Path) -> None:
|
|
26
|
+
self.root = storage_root / "sessions"
|
|
27
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
def _dir(self, session_id: str) -> Path:
|
|
30
|
+
return self.root / session_id
|
|
31
|
+
|
|
32
|
+
def create(self, session: Session) -> Session:
|
|
33
|
+
d = self._dir(session.id)
|
|
34
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
self._write_meta(session)
|
|
36
|
+
log.debug("created session %s in %s", session.id, session.workspace)
|
|
37
|
+
return session
|
|
38
|
+
|
|
39
|
+
def _write_meta(self, session: Session) -> None:
|
|
40
|
+
session.touch()
|
|
41
|
+
(self._dir(session.id) / "meta.json").write_text(
|
|
42
|
+
session.model_dump_json(indent=2), encoding="utf-8"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def get(self, session_id: str) -> Session | None:
|
|
46
|
+
path = self._dir(session_id) / "meta.json"
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return None
|
|
49
|
+
return Session.model_validate_json(path.read_text(encoding="utf-8"))
|
|
50
|
+
|
|
51
|
+
def update(self, session: Session) -> None:
|
|
52
|
+
if not self._dir(session.id).exists():
|
|
53
|
+
raise FileNotFoundError(f"session {session.id} not found")
|
|
54
|
+
self._write_meta(session)
|
|
55
|
+
|
|
56
|
+
def list(self) -> list[Session]:
|
|
57
|
+
sessions: list[Session] = []
|
|
58
|
+
for child in sorted(self.root.iterdir()):
|
|
59
|
+
meta = child / "meta.json"
|
|
60
|
+
if meta.exists():
|
|
61
|
+
sessions.append(Session.model_validate_json(meta.read_text(encoding="utf-8")))
|
|
62
|
+
return sessions
|
|
63
|
+
|
|
64
|
+
def set_status(self, session_id: str, status: SessionStatus) -> Session | None:
|
|
65
|
+
s = self.get(session_id)
|
|
66
|
+
if s is None:
|
|
67
|
+
return None
|
|
68
|
+
s.status = status
|
|
69
|
+
self.update(s)
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
def session_dir(self, session_id: str) -> Path:
|
|
73
|
+
return self._dir(session_id)
|
|
74
|
+
|
|
75
|
+
def event_log(self, session_id: str) -> EventLog:
|
|
76
|
+
return EventLog(self._dir(session_id) / "events.jsonl")
|
|
77
|
+
|
|
78
|
+
def transcript_log(self, session_id: str) -> EventLog:
|
|
79
|
+
return EventLog(self._dir(session_id) / "transcript.jsonl")
|
|
80
|
+
|
|
81
|
+
def append_message(self, session_id: str, message: Message) -> None:
|
|
82
|
+
"""Append one Message to transcript.jsonl for restorable resume."""
|
|
83
|
+
path = self._dir(session_id) / "transcript.jsonl"
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
line = message.model_dump_json()
|
|
86
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
87
|
+
fh.write(line + "\n")
|
|
88
|
+
|
|
89
|
+
def read_messages(self, session_id: str, limit: int = _MAX_RESUME_MESSAGES) -> list[Message]:
|
|
90
|
+
"""Read transcript.jsonl back into Message objects (most recent `limit`)."""
|
|
91
|
+
path = self._dir(session_id) / "transcript.jsonl"
|
|
92
|
+
if not path.exists():
|
|
93
|
+
return []
|
|
94
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
95
|
+
messages: list[Message] = []
|
|
96
|
+
for line in lines[-limit:]:
|
|
97
|
+
try:
|
|
98
|
+
messages.append(Message.model_validate_json(line))
|
|
99
|
+
except Exception: # noqa: BLE001 - skip corrupt lines
|
|
100
|
+
continue
|
|
101
|
+
return messages
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Task model.
|
|
2
|
+
|
|
3
|
+
Structured task state — not prose steps. Tasks belong to a session, track status
|
|
4
|
+
through a real state machine, and support dependencies.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _new_id() -> str:
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
return uuid.uuid4().hex[:10]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskStatus(StrEnum):
|
|
22
|
+
PENDING = "pending"
|
|
23
|
+
ACTIVE = "active"
|
|
24
|
+
COMPLETED = "completed"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
BLOCKED = "blocked"
|
|
27
|
+
SKIPPED = "skipped"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Task(BaseModel):
|
|
31
|
+
id: str = Field(default_factory=_new_id)
|
|
32
|
+
session_id: str
|
|
33
|
+
title: str
|
|
34
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
35
|
+
summary: str = ""
|
|
36
|
+
error: str = ""
|
|
37
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
38
|
+
notes: list[str] = Field(default_factory=list)
|
|
39
|
+
meta: dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
41
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
42
|
+
|
|
43
|
+
def touch(self) -> None:
|
|
44
|
+
self.updated_at = datetime.now(UTC)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Task store: per-session task state persisted to tasks.json."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from reidx.diagnostics.logger import get_logger
|
|
8
|
+
from reidx.tasks.models import Task, TaskStatus
|
|
9
|
+
|
|
10
|
+
log = get_logger("reidx.tasks")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskStore:
|
|
14
|
+
def __init__(self, storage_root: Path, session_id: str) -> None:
|
|
15
|
+
self.path = storage_root / "sessions" / session_id / "tasks.json"
|
|
16
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
self.session_id = session_id
|
|
18
|
+
|
|
19
|
+
def _read(self) -> dict[str, Task]:
|
|
20
|
+
if not self.path.exists():
|
|
21
|
+
return {}
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
text = self.path.read_text(encoding="utf-8").strip()
|
|
25
|
+
if not text:
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
data: dict[str, Task] = {}
|
|
29
|
+
for raw in json.loads(text).get("tasks", []):
|
|
30
|
+
t = Task.model_validate(raw)
|
|
31
|
+
data[t.id] = t
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
def _write(self, tasks: dict[str, Task]) -> None:
|
|
35
|
+
import json
|
|
36
|
+
|
|
37
|
+
payload = {"tasks": [t.model_dump(mode="json") for t in tasks.values()]}
|
|
38
|
+
self.path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
def create(
|
|
41
|
+
self,
|
|
42
|
+
title: str,
|
|
43
|
+
depends_on: list[str] | None = None,
|
|
44
|
+
meta: dict[str, Any] | None = None,
|
|
45
|
+
) -> Task:
|
|
46
|
+
tasks = self._read()
|
|
47
|
+
t = Task(
|
|
48
|
+
session_id=self.session_id,
|
|
49
|
+
title=title,
|
|
50
|
+
depends_on=depends_on or [],
|
|
51
|
+
meta=meta or {},
|
|
52
|
+
)
|
|
53
|
+
tasks[t.id] = t
|
|
54
|
+
self._write(tasks)
|
|
55
|
+
log.debug("created task %s: %s", t.id, title)
|
|
56
|
+
return t
|
|
57
|
+
|
|
58
|
+
def get(self, task_id: str) -> Task | None:
|
|
59
|
+
return self._read().get(task_id)
|
|
60
|
+
|
|
61
|
+
def list(self) -> list[Task]:
|
|
62
|
+
return list(self._read().values())
|
|
63
|
+
|
|
64
|
+
def update_status(self, task_id: str, status: TaskStatus, summary: str = "", error: str = "") -> Task | None:
|
|
65
|
+
tasks = self._read()
|
|
66
|
+
t = tasks.get(task_id)
|
|
67
|
+
if t is None:
|
|
68
|
+
return None
|
|
69
|
+
t.status = status
|
|
70
|
+
if summary:
|
|
71
|
+
t.summary = summary
|
|
72
|
+
if error:
|
|
73
|
+
t.error = error
|
|
74
|
+
t.touch()
|
|
75
|
+
self._write(tasks)
|
|
76
|
+
return t
|
|
77
|
+
|
|
78
|
+
def add_note(self, task_id: str, note: str) -> Task | None:
|
|
79
|
+
tasks = self._read()
|
|
80
|
+
t = tasks.get(task_id)
|
|
81
|
+
if t is None:
|
|
82
|
+
return None
|
|
83
|
+
t.notes.append(note)
|
|
84
|
+
t.touch()
|
|
85
|
+
self._write(tasks)
|
|
86
|
+
return t
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from reidx.tools.base import BaseTool, ToolContext, ToolDefinition, ToolResult
|
|
2
|
+
from reidx.tools.file_tools import (
|
|
3
|
+
FindFilesTool,
|
|
4
|
+
GrepFilesTool,
|
|
5
|
+
ListDirTool,
|
|
6
|
+
PatchFileTool,
|
|
7
|
+
ReadFileTool,
|
|
8
|
+
WriteFileTool,
|
|
9
|
+
register_file_tools,
|
|
10
|
+
)
|
|
11
|
+
from reidx.tools.registry import ToolRegistry
|
|
12
|
+
from reidx.tools.shell_tool import RunCommandTool, register_shell_tool
|
|
13
|
+
from reidx.tools.spawn_agent import SpawnAgentTool, register_spawn_agent
|
|
14
|
+
from reidx.tools.web_tools import WebSearchTool, register_web_tools
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BaseTool",
|
|
18
|
+
"FindFilesTool",
|
|
19
|
+
"GrepFilesTool",
|
|
20
|
+
"ListDirTool",
|
|
21
|
+
"PatchFileTool",
|
|
22
|
+
"ReadFileTool",
|
|
23
|
+
"RunCommandTool",
|
|
24
|
+
"SpawnAgentTool",
|
|
25
|
+
"ToolContext",
|
|
26
|
+
"ToolDefinition",
|
|
27
|
+
"ToolRegistry",
|
|
28
|
+
"ToolResult",
|
|
29
|
+
"WebSearchTool",
|
|
30
|
+
"WriteFileTool",
|
|
31
|
+
"register_file_tools",
|
|
32
|
+
"register_shell_tool",
|
|
33
|
+
"register_spawn_agent",
|
|
34
|
+
"register_web_tools",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def default_registry() -> ToolRegistry:
|
|
39
|
+
reg = ToolRegistry()
|
|
40
|
+
register_file_tools(reg)
|
|
41
|
+
register_shell_tool(reg)
|
|
42
|
+
register_web_tools(reg)
|
|
43
|
+
register_spawn_agent(reg)
|
|
44
|
+
return reg
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Tool system contracts.
|
|
2
|
+
|
|
3
|
+
A tool has a definition (name + description + JSON-schema parameters + risk level)
|
|
4
|
+
and an execute() entry point. Tools receive a ToolContext carrying the workspace,
|
|
5
|
+
writable roots, the policy engine, and an approver callable used to resolve PROMPT
|
|
6
|
+
decisions into ALLOW/DENY. Tool results are structured, never raised as exceptions
|
|
7
|
+
when avoidable — failures become ToolResult(ok=False, error=...).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from reidx.policy.engine import PolicyEngine
|
|
20
|
+
from reidx.policy.models import PermissionDecision, RiskLevel
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolDefinition(BaseModel):
|
|
24
|
+
name: str
|
|
25
|
+
description: str
|
|
26
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
27
|
+
risk: RiskLevel = RiskLevel.MEDIUM
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolResult(BaseModel):
|
|
31
|
+
ok: bool = True
|
|
32
|
+
output: str = ""
|
|
33
|
+
error: str = ""
|
|
34
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def fail(cls, error: str) -> ToolResult:
|
|
38
|
+
return cls(ok=False, error=error)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def ok_(cls, output: str = "", **data: Any) -> ToolResult:
|
|
42
|
+
return cls(ok=True, output=output, data=data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Approver signature: (prompt_text) -> bool. Supplied by the UI layer.
|
|
46
|
+
Approver = Callable[[str], bool]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ToolContext:
|
|
51
|
+
workspace_root: Path
|
|
52
|
+
policy: PolicyEngine
|
|
53
|
+
writable_roots: list[Path] = field(default_factory=list)
|
|
54
|
+
approver: Approver | None = None
|
|
55
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
def resolve_decision(self, prompt_text: str) -> PermissionDecision:
|
|
58
|
+
"""Resolve a PROMPT decision via the approver; defaults to DENY if none set."""
|
|
59
|
+
if self.approver is None:
|
|
60
|
+
return PermissionDecision.DENY
|
|
61
|
+
return PermissionDecision.ALLOW if self.approver(prompt_text) else PermissionDecision.DENY
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BaseTool(ABC):
|
|
65
|
+
@property
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def definition(self) -> ToolDefinition:
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
def schema(self) -> dict[str, Any]:
|
|
75
|
+
"""OpenAI-style function schema for provider tool passing."""
|
|
76
|
+
d = self.definition
|
|
77
|
+
return {
|
|
78
|
+
"type": "function",
|
|
79
|
+
"function": {
|
|
80
|
+
"name": d.name,
|
|
81
|
+
"description": d.description,
|
|
82
|
+
"parameters": d.parameters or {"type": "object", "properties": {}},
|
|
83
|
+
},
|
|
84
|
+
}
|