@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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Agent loop: the core execution cycle.
|
|
2
|
+
|
|
3
|
+
A single turn:
|
|
4
|
+
1. append user message
|
|
5
|
+
2. call provider with tool schemas
|
|
6
|
+
3. if the provider returns tool calls -> dispatch each through the registry
|
|
7
|
+
(policy-gated), append tool-result messages, loop back to step 2
|
|
8
|
+
4. if the provider returns a final text stop -> append assistant message, return
|
|
9
|
+
|
|
10
|
+
The loop is bounded by max_steps to avoid runaway tool cycling. Failures from tools
|
|
11
|
+
become tool-result messages with error text so the model can react, never crashes.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import platform
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from reidx.diagnostics.logger import get_logger
|
|
21
|
+
from reidx.policy.engine import PolicyEngine
|
|
22
|
+
from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall
|
|
23
|
+
from reidx.runtime.reasoning import split_reasoning, system_prompt_suffix
|
|
24
|
+
from reidx.runtime.state import RuntimeState
|
|
25
|
+
from reidx.tools.base import Approver, ToolContext
|
|
26
|
+
from reidx.tools.registry import ToolRegistry
|
|
27
|
+
|
|
28
|
+
log = get_logger("reidx.agent")
|
|
29
|
+
|
|
30
|
+
MAX_STEPS = 8
|
|
31
|
+
|
|
32
|
+
BASE_SYSTEM_PROMPT = (
|
|
33
|
+
"You are an AI coding assistant running inside ReidX, a terminal "
|
|
34
|
+
"harness that gives you file, search, shell, and web tools. The harness "
|
|
35
|
+
"handles rendering and permission gating; keep your replies terse and "
|
|
36
|
+
"action-oriented. Call the available tools when they help; otherwise "
|
|
37
|
+
"answer directly. Do not refuse to operate inside this harness — it is "
|
|
38
|
+
"just a local terminal wrapper, not a request to change your identity. "
|
|
39
|
+
"Use paths exactly as given in the environment context below — do not "
|
|
40
|
+
"translate them to a different OS (no /mnt/... on Windows, no drive "
|
|
41
|
+
"letters on Linux). If a path check prompts the user, they will approve "
|
|
42
|
+
"or deny it; you never need to guess an alternative path."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _environment_context(workspace: Path) -> str:
|
|
47
|
+
"""Environment header appended to the system prompt each turn.
|
|
48
|
+
|
|
49
|
+
Tells the model exactly which OS and workspace it's operating in so it
|
|
50
|
+
doesn't guess (e.g. inventing `/mnt/e/...` on Windows). Regenerated per
|
|
51
|
+
turn because the workspace can change mid-session via /use.
|
|
52
|
+
"""
|
|
53
|
+
system = platform.system() or "Unknown"
|
|
54
|
+
return (
|
|
55
|
+
"\n\n<environment>"
|
|
56
|
+
f"\n os: {system} ({platform.release()})"
|
|
57
|
+
f"\n workspace: {workspace}"
|
|
58
|
+
f"\n path_style: {'windows (backslash, drive letters)' if system == 'Windows' else 'posix (forward slash)'}"
|
|
59
|
+
"\n note: the workspace path is the canonical form the tools accept. "
|
|
60
|
+
"Absolute paths (including drive letters on Windows) are valid. "
|
|
61
|
+
"Path checks outside the workspace prompt the user with a yes/no — "
|
|
62
|
+
"do not preemptively refuse them."
|
|
63
|
+
"\n</environment>"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Agent:
|
|
68
|
+
"""Generic tool-calling agent. Role specialization can subclass later."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
provider: BaseProvider,
|
|
73
|
+
tools: ToolRegistry,
|
|
74
|
+
policy: PolicyEngine,
|
|
75
|
+
*,
|
|
76
|
+
base_system_prompt: str = BASE_SYSTEM_PROMPT,
|
|
77
|
+
context_extras: dict | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.provider = provider
|
|
80
|
+
self.tools = tools
|
|
81
|
+
self.policy = policy
|
|
82
|
+
self.base_system_prompt = base_system_prompt
|
|
83
|
+
# Copied into every ToolContext.extra — spawn_agent reads its
|
|
84
|
+
# orchestrator handle from here (see tools/spawn_agent.py).
|
|
85
|
+
self.context_extras = context_extras or {}
|
|
86
|
+
|
|
87
|
+
def _context(
|
|
88
|
+
self, state: RuntimeState, writable_roots: list, approver: Approver | None
|
|
89
|
+
) -> ToolContext:
|
|
90
|
+
return ToolContext(
|
|
91
|
+
workspace_root=state.session.workspace,
|
|
92
|
+
policy=self.policy,
|
|
93
|
+
writable_roots=writable_roots,
|
|
94
|
+
approver=approver,
|
|
95
|
+
extra=dict(self.context_extras),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _ensure_system(self, state: RuntimeState) -> None:
|
|
99
|
+
# Rebuilt every turn (not just inserted once) so changing /effort or
|
|
100
|
+
# the Left/Right effort cycle mid-session takes effect on the very
|
|
101
|
+
# next turn instead of being frozen at whatever it was on turn one.
|
|
102
|
+
prompt = (
|
|
103
|
+
self.base_system_prompt
|
|
104
|
+
+ _environment_context(state.session.workspace)
|
|
105
|
+
+ system_prompt_suffix(state.session.reasoning_effort)
|
|
106
|
+
)
|
|
107
|
+
if not state.messages or state.messages[0].role != "system":
|
|
108
|
+
state.messages.insert(0, Message(role="system", content=prompt))
|
|
109
|
+
else:
|
|
110
|
+
state.messages[0].content = prompt
|
|
111
|
+
|
|
112
|
+
def run_turn(
|
|
113
|
+
self,
|
|
114
|
+
state: RuntimeState,
|
|
115
|
+
user_input: str,
|
|
116
|
+
*,
|
|
117
|
+
writable_roots: list | None = None,
|
|
118
|
+
approver: Approver | None = None,
|
|
119
|
+
max_steps: int = MAX_STEPS,
|
|
120
|
+
cancel: Callable[[], bool] | None = None,
|
|
121
|
+
) -> tuple[str, list[dict]]:
|
|
122
|
+
"""Execute one user turn. Returns (final_text, tool_result_log).
|
|
123
|
+
|
|
124
|
+
The orchestrator owns the policy mode; this loop only reads it. One assistant
|
|
125
|
+
message is appended per provider turn, carrying both content and tool_calls.
|
|
126
|
+
|
|
127
|
+
`cancel`, if given, is polled at each safe point (before the next
|
|
128
|
+
provider call, and between individual tool calls within a step) so a
|
|
129
|
+
user-triggered stop (e.g. Escape in the TUI) takes effect at the next
|
|
130
|
+
such point rather than needing to kill an in-flight network call —
|
|
131
|
+
it can't interrupt a `provider.chat`/tool call already in progress.
|
|
132
|
+
"""
|
|
133
|
+
self._ensure_system(state)
|
|
134
|
+
state.messages.append(Message(role="user", content=user_input))
|
|
135
|
+
ctx = self._context(state, writable_roots or [], approver)
|
|
136
|
+
tool_log: list[dict] = []
|
|
137
|
+
final_text = ""
|
|
138
|
+
state.last_thinking = None # fresh per turn; the UI reads it after run_turn
|
|
139
|
+
|
|
140
|
+
for _step in range(max_steps):
|
|
141
|
+
if cancel is not None and cancel():
|
|
142
|
+
final_text = final_text or "[cancelled by user]"
|
|
143
|
+
break
|
|
144
|
+
resp: ProviderResponse = self.provider.chat(
|
|
145
|
+
state.messages, self.tools.schemas(), state.session.model
|
|
146
|
+
)
|
|
147
|
+
# Latest call's usage, not summed — see RuntimeState's field docstring.
|
|
148
|
+
state.last_usage_prompt_tokens = resp.usage.prompt_tokens
|
|
149
|
+
state.last_usage_completion_tokens = resp.usage.completion_tokens
|
|
150
|
+
# Separate chain-of-thought from the answer. The reasoning is ephemeral:
|
|
151
|
+
# only the clean answer is stored in the transcript / fed back to the model.
|
|
152
|
+
thinking, answer = split_reasoning(resp.text)
|
|
153
|
+
if thinking:
|
|
154
|
+
state.last_thinking = thinking
|
|
155
|
+
# Single assistant message per turn, carrying both content and tool_calls.
|
|
156
|
+
state.messages.append(
|
|
157
|
+
Message(role="assistant", content=answer, tool_calls=resp.tool_calls)
|
|
158
|
+
)
|
|
159
|
+
if answer:
|
|
160
|
+
final_text = answer
|
|
161
|
+
|
|
162
|
+
if not resp.tool_calls:
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
cancelled_mid_tools = False
|
|
166
|
+
for call in resp.tool_calls:
|
|
167
|
+
if cancel is not None and cancel():
|
|
168
|
+
cancelled_mid_tools = True
|
|
169
|
+
break
|
|
170
|
+
result = self.tools.dispatch(call.name, call.arguments, ctx)
|
|
171
|
+
tool_log.append(
|
|
172
|
+
{"name": call.name, "args": call.arguments, "ok": result.ok, "error": result.error}
|
|
173
|
+
)
|
|
174
|
+
state.messages.append(
|
|
175
|
+
Message(
|
|
176
|
+
role="tool",
|
|
177
|
+
content=result.output if result.ok else f"ERROR: {result.error}\n{result.output}",
|
|
178
|
+
tool_call_id=call.id,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
state.last_tool_results = tool_log
|
|
182
|
+
if cancelled_mid_tools:
|
|
183
|
+
final_text = final_text or "[cancelled by user]"
|
|
184
|
+
break
|
|
185
|
+
else:
|
|
186
|
+
final_text = final_text or "[agent] step budget exhausted without a final answer."
|
|
187
|
+
|
|
188
|
+
state.turns += 1
|
|
189
|
+
return final_text, tool_log
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Re-exported for convenience so callers can build agents from config.
|
|
193
|
+
def build_agent(provider: BaseProvider, tools: ToolRegistry, policy: PolicyEngine) -> Agent:
|
|
194
|
+
return Agent(provider, tools, policy)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Silence unused import warnings for re-exports.
|
|
198
|
+
__all__ = ["Agent", "build_agent", "ToolCall", "Any"]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Orchestrator: ties session + tasks + agent into a coherent runtime.
|
|
2
|
+
|
|
3
|
+
Owns the lifetime of a session's runtime: starting, submitting tasks, resuming,
|
|
4
|
+
and persisting transcript/events. The orchestrator is the single place where
|
|
5
|
+
session, task, agent, and policy meet — UI and automation layers call into it.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from reidx.config.models import Config
|
|
13
|
+
from reidx.diagnostics.logger import get_logger
|
|
14
|
+
from reidx.goals import Goal, GoalStore
|
|
15
|
+
from reidx.nyx import NYX_SYSTEM_PROMPT
|
|
16
|
+
from reidx.policy.engine import PolicyEngine
|
|
17
|
+
from reidx.provider.base import BaseProvider
|
|
18
|
+
from reidx.provider.registry import ProviderRegistry
|
|
19
|
+
from reidx.runtime.agent import BASE_SYSTEM_PROMPT, Agent
|
|
20
|
+
from reidx.runtime.state import RuntimeState
|
|
21
|
+
from reidx.session.models import Session, SessionStatus
|
|
22
|
+
from reidx.session.store import SessionStore
|
|
23
|
+
from reidx.tasks.models import TaskStatus
|
|
24
|
+
from reidx.tasks.store import TaskStore
|
|
25
|
+
from reidx.tools.base import Approver
|
|
26
|
+
from reidx.tools.registry import ToolRegistry
|
|
27
|
+
from reidx.workflows.store import WorkflowStore
|
|
28
|
+
|
|
29
|
+
log = get_logger("reidx.runtime")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Orchestrator:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config: Config,
|
|
36
|
+
provider: BaseProvider,
|
|
37
|
+
tools: ToolRegistry,
|
|
38
|
+
providers: ProviderRegistry | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.config = config
|
|
41
|
+
self.provider = provider
|
|
42
|
+
self.tools = tools
|
|
43
|
+
self.providers = providers
|
|
44
|
+
self.policy = PolicyEngine(config)
|
|
45
|
+
self.session_store = SessionStore(config.storage_root or (Path.home() / ".reidx"))
|
|
46
|
+
self.workflow_store = WorkflowStore(config.storage_root or (Path.home() / ".reidx"))
|
|
47
|
+
# Subagent runtime (spawn_agent tool + TUI panel subscribe to this).
|
|
48
|
+
# Imported lazily to avoid an import cycle: tools/spawn_agent constructs
|
|
49
|
+
# a child Agent using the registries held here.
|
|
50
|
+
from reidx.runtime.subagent import SubagentManager # noqa: PLC0415
|
|
51
|
+
self.subagents = SubagentManager()
|
|
52
|
+
self.agent = Agent(provider, tools, self.policy, context_extras={"orchestrator": self})
|
|
53
|
+
self.state: RuntimeState | None = None
|
|
54
|
+
self.nyx_enabled = False
|
|
55
|
+
|
|
56
|
+
def use_provider(self, name: str) -> BaseProvider:
|
|
57
|
+
"""Session-scoped provider swap. Rebuilds the Agent so subsequent turns
|
|
58
|
+
route through the new provider. Persistent default (`config.default_provider`)
|
|
59
|
+
is intentionally NOT changed — stub stays default across restarts unless
|
|
60
|
+
explicitly asked via a future --set-default flag."""
|
|
61
|
+
if self.providers is None:
|
|
62
|
+
raise RuntimeError("no provider registry attached")
|
|
63
|
+
provider = self.providers.get(name)
|
|
64
|
+
self.provider = provider
|
|
65
|
+
self.agent = Agent(
|
|
66
|
+
provider,
|
|
67
|
+
self.tools,
|
|
68
|
+
self.policy,
|
|
69
|
+
base_system_prompt=NYX_SYSTEM_PROMPT if self.nyx_enabled else BASE_SYSTEM_PROMPT,
|
|
70
|
+
context_extras={"orchestrator": self},
|
|
71
|
+
)
|
|
72
|
+
if self.state is not None:
|
|
73
|
+
self.state.session.provider = name
|
|
74
|
+
prov_cfg = self.config.providers.get(name)
|
|
75
|
+
if prov_cfg and prov_cfg.default_model:
|
|
76
|
+
self.state.session.model = prov_cfg.default_model
|
|
77
|
+
elif getattr(provider, "default_model", ""):
|
|
78
|
+
self.state.session.model = provider.default_model
|
|
79
|
+
self.session_store.update(self.state.session)
|
|
80
|
+
return provider
|
|
81
|
+
|
|
82
|
+
def start_session(self, title: str = "") -> Session:
|
|
83
|
+
workspace = (self.config.workspace_root or Path.cwd()).resolve()
|
|
84
|
+
session = Session(
|
|
85
|
+
title=title or "untitled",
|
|
86
|
+
workspace=workspace,
|
|
87
|
+
provider=self.config.default_provider,
|
|
88
|
+
model=self._default_model(),
|
|
89
|
+
permission_mode=self.config.policy.default_mode,
|
|
90
|
+
)
|
|
91
|
+
self.session_store.create(session)
|
|
92
|
+
self.state = RuntimeState(session=session)
|
|
93
|
+
self.session_store.event_log(session.id).write("session_start", {"title": session.title})
|
|
94
|
+
return session
|
|
95
|
+
|
|
96
|
+
def _default_model(self) -> str:
|
|
97
|
+
prov = self.config.providers.get(self.config.default_provider)
|
|
98
|
+
return prov.default_model if prov else "stub-v0"
|
|
99
|
+
|
|
100
|
+
def resume_session(self, session_id: str) -> Session:
|
|
101
|
+
session = self.session_store.get(session_id)
|
|
102
|
+
if session is None:
|
|
103
|
+
raise KeyError(f"session {session_id} not found")
|
|
104
|
+
self.session_store.set_status(session.id, SessionStatus.ACTIVE)
|
|
105
|
+
self.state = RuntimeState(session=session)
|
|
106
|
+
self.policy.set_mode(session.permission_mode)
|
|
107
|
+
# Restore prior transcript into in-memory state for real continuation.
|
|
108
|
+
self.state.messages = self.session_store.read_messages(session.id)
|
|
109
|
+
self.session_store.event_log(session.id).write("session_resume", {"messages": len(self.state.messages)})
|
|
110
|
+
return session
|
|
111
|
+
|
|
112
|
+
def task_store(self) -> TaskStore:
|
|
113
|
+
if self.state is None:
|
|
114
|
+
raise RuntimeError("no active session")
|
|
115
|
+
return TaskStore(self.config.storage_root or (Path.home() / ".reidx"), self.state.session.id)
|
|
116
|
+
|
|
117
|
+
def goal_store(self) -> GoalStore:
|
|
118
|
+
if self.state is None:
|
|
119
|
+
raise RuntimeError("no active session")
|
|
120
|
+
return GoalStore(self.config.storage_root or (Path.home() / ".reidx"), self.state.session.id)
|
|
121
|
+
|
|
122
|
+
def submit_task(
|
|
123
|
+
self,
|
|
124
|
+
user_input: str,
|
|
125
|
+
*,
|
|
126
|
+
approver: Approver | None = None,
|
|
127
|
+
title: str | None = None,
|
|
128
|
+
cancel: Callable[[], bool] | None = None,
|
|
129
|
+
) -> dict:
|
|
130
|
+
"""Run one user turn against the agent, tracking it as a Task.
|
|
131
|
+
|
|
132
|
+
`cancel`, if given, is forwarded to `Agent.run_turn` and polled at
|
|
133
|
+
safe points so a user-triggered stop (e.g. Escape in the TUI) ends
|
|
134
|
+
the turn with whatever partial answer/tool results it already has,
|
|
135
|
+
instead of running to completion.
|
|
136
|
+
"""
|
|
137
|
+
if self.state is None:
|
|
138
|
+
raise RuntimeError("no active session; call start_session first")
|
|
139
|
+
store = self.task_store()
|
|
140
|
+
active_goal = self.goal_store().active()
|
|
141
|
+
task_meta = {}
|
|
142
|
+
if active_goal is not None:
|
|
143
|
+
task_meta = {
|
|
144
|
+
"goal_id": active_goal.id,
|
|
145
|
+
"goal_title": active_goal.title,
|
|
146
|
+
}
|
|
147
|
+
if active_goal.active_node_id:
|
|
148
|
+
task_meta["goal_node_id"] = active_goal.active_node_id
|
|
149
|
+
task = store.create(title or user_input[:60], meta=task_meta)
|
|
150
|
+
if active_goal is not None:
|
|
151
|
+
self.goal_store().add_task_link(
|
|
152
|
+
active_goal.id,
|
|
153
|
+
task.id,
|
|
154
|
+
node_id=active_goal.active_node_id,
|
|
155
|
+
)
|
|
156
|
+
store.update_status(task.id, TaskStatus.ACTIVE)
|
|
157
|
+
self.state.active_task_id = task.id
|
|
158
|
+
|
|
159
|
+
# Record message count so we can persist only the new messages from this turn.
|
|
160
|
+
pre_turn_count = len(self.state.messages)
|
|
161
|
+
writable_roots = [r.resolve() for r in self.config.policy.additional_writable_roots]
|
|
162
|
+
final_text, tool_log = self.agent.run_turn(
|
|
163
|
+
self.state, user_input, writable_roots=writable_roots, approver=approver, cancel=cancel
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Persist new messages incrementally for restorable resume.
|
|
167
|
+
sid = self.state.session.id
|
|
168
|
+
for msg in self.state.messages[pre_turn_count:]:
|
|
169
|
+
self.session_store.append_message(sid, msg)
|
|
170
|
+
|
|
171
|
+
# Turn summary in events.jsonl (human-readable, separate from restorable transcript).
|
|
172
|
+
self.session_store.event_log(sid).write(
|
|
173
|
+
"task_complete", {"task_id": task.id, "tools": len(tool_log)}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Derive task status from the turn outcome.
|
|
177
|
+
cancelled = final_text.startswith("[cancelled by user]")
|
|
178
|
+
exhausted = final_text.startswith("[agent] step budget exhausted")
|
|
179
|
+
all_tools_failed = bool(tool_log) and not any(entry["ok"] for entry in tool_log)
|
|
180
|
+
if cancelled:
|
|
181
|
+
store.update_status(task.id, TaskStatus.SKIPPED, summary=final_text[:200])
|
|
182
|
+
elif exhausted or all_tools_failed:
|
|
183
|
+
store.update_status(task.id, TaskStatus.FAILED, error=final_text[:200])
|
|
184
|
+
else:
|
|
185
|
+
store.update_status(task.id, TaskStatus.COMPLETED, summary=final_text[:200])
|
|
186
|
+
self.session_store.update(self.state.session)
|
|
187
|
+
return {
|
|
188
|
+
"task_id": task.id,
|
|
189
|
+
"text": final_text,
|
|
190
|
+
"tools": tool_log,
|
|
191
|
+
"thinking": self.state.last_thinking,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def list_tasks(self) -> list:
|
|
195
|
+
if self.state is None:
|
|
196
|
+
return []
|
|
197
|
+
return self.task_store().list()
|
|
198
|
+
|
|
199
|
+
def list_goals(self) -> list[Goal]:
|
|
200
|
+
if self.state is None:
|
|
201
|
+
return []
|
|
202
|
+
return self.goal_store().list()
|
|
203
|
+
|
|
204
|
+
def set_nyx(self, enabled: bool) -> None:
|
|
205
|
+
"""Toggle Nyx (redteam/offensive-security) persona. Rebuilds the Agent
|
|
206
|
+
with the swapped system prompt; tool registry and policy engine are
|
|
207
|
+
untouched — those are the actual safety boundary, not the prompt."""
|
|
208
|
+
self.nyx_enabled = enabled
|
|
209
|
+
self.agent = Agent(
|
|
210
|
+
self.provider,
|
|
211
|
+
self.tools,
|
|
212
|
+
self.policy,
|
|
213
|
+
base_system_prompt=NYX_SYSTEM_PROMPT if enabled else BASE_SYSTEM_PROMPT,
|
|
214
|
+
context_extras={"orchestrator": self},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def set_permission_mode(self, mode) -> None: # type: ignore[no-untyped-def]
|
|
218
|
+
# Single source of truth: update policy engine + session + persist.
|
|
219
|
+
self.policy.set_mode(mode)
|
|
220
|
+
if self.state is None:
|
|
221
|
+
self.config.policy.default_mode = mode
|
|
222
|
+
return
|
|
223
|
+
self.state.session.permission_mode = mode
|
|
224
|
+
self.session_store.update(self.state.session)
|
|
225
|
+
|
|
226
|
+
def rewind(self) -> None:
|
|
227
|
+
"""Drop messages back to before the last user turn (stub for deeper rewind later)."""
|
|
228
|
+
if self.state is None or not self.state.messages:
|
|
229
|
+
return
|
|
230
|
+
msgs = self.state.messages
|
|
231
|
+
# Find the last user message and drop from there onward.
|
|
232
|
+
for i in range(len(msgs) - 1, -1, -1):
|
|
233
|
+
if msgs[i].role == "user":
|
|
234
|
+
del msgs[i:]
|
|
235
|
+
break
|
|
236
|
+
# Persist the truncated transcript by rewriting transcript.jsonl.
|
|
237
|
+
sid = self.state.session.id
|
|
238
|
+
path = self.session_store.session_dir(sid) / "transcript.jsonl"
|
|
239
|
+
if path.exists():
|
|
240
|
+
path.write_text(
|
|
241
|
+
"\n".join(m.model_dump_json() for m in msgs) + ("\n" if msgs else ""),
|
|
242
|
+
encoding="utf-8",
|
|
243
|
+
)
|
|
244
|
+
self.session_store.event_log(sid).write("rewind", {"remaining": len(msgs)})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Chain-of-thought support for prompt-based reasoning.
|
|
2
|
+
|
|
3
|
+
The model is asked (via the agent's system prompt) to wrap its step-by-step
|
|
4
|
+
reasoning in <think>…</think> tags before the final answer. `split_reasoning`
|
|
5
|
+
separates that reasoning from the answer so the UI can show the thinking above
|
|
6
|
+
the response and keep it out of the stored transcript (thinking is ephemeral).
|
|
7
|
+
|
|
8
|
+
This is prompt-based CoT — it works with any instruction-following model behind
|
|
9
|
+
the provider, including OpenAI-family models served through the Reidchat proxy,
|
|
10
|
+
which have no native Anthropic "thinking" content blocks.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
# Appended to the agent's system prompt to elicit the reasoning block. This is
|
|
17
|
+
# the "medium" tier and also the fallback for any unrecognized effort value.
|
|
18
|
+
COT_SYSTEM_SUFFIX = (
|
|
19
|
+
"\n\nAlways begin your reply with concise, first-person step-by-step reasoning "
|
|
20
|
+
"enclosed in <think> and </think> tags. Put nothing but that reasoning between "
|
|
21
|
+
"the tags. After the closing </think> tag, write your final answer for the user."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Session.reasoning_effort -> system-prompt suffix. This is prompt-based, not a
|
|
25
|
+
# native provider reasoning-effort/thinking-budget parameter — no provider
|
|
26
|
+
# integration currently sends a structured effort/thinking API param, so this
|
|
27
|
+
# is the only lever available that works uniformly across providers.
|
|
28
|
+
# (Anthropic's Messages API does support native extended thinking via a
|
|
29
|
+
# `thinking` payload field, which would give sturdier, non-refusable reasoning
|
|
30
|
+
# output than this tag-based convention — deferred: it requires preserving
|
|
31
|
+
# `thinking` content blocks across multi-turn tool-call replays per Anthropic's
|
|
32
|
+
# docs, which needs verifying against a live API before shipping.)
|
|
33
|
+
EFFORT_SYSTEM_SUFFIXES: dict[str, str] = {
|
|
34
|
+
"low": (
|
|
35
|
+
"\n\nKeep internal reasoning minimal. For simple requests, skip the <think> "
|
|
36
|
+
"block entirely and answer directly. Only use a brief <think>...</think> "
|
|
37
|
+
"block (1-2 sentences) when a request is genuinely ambiguous or multi-step."
|
|
38
|
+
),
|
|
39
|
+
"medium": COT_SYSTEM_SUFFIX,
|
|
40
|
+
"high": (
|
|
41
|
+
"\n\nAlways begin your reply with thorough, first-person step-by-step "
|
|
42
|
+
"reasoning enclosed in <think> and </think> tags — work through the request "
|
|
43
|
+
"carefully, note relevant constraints, and consider your approach before "
|
|
44
|
+
"answering. Put nothing but that reasoning between the tags. After the "
|
|
45
|
+
"closing </think> tag, write your final answer for the user."
|
|
46
|
+
),
|
|
47
|
+
"xhigh": (
|
|
48
|
+
"\n\nAlways begin your reply with exhaustive, first-person step-by-step "
|
|
49
|
+
"reasoning enclosed in <think> and </think> tags — consider multiple "
|
|
50
|
+
"approaches, weigh tradeoffs, double-check your logic, and note edge cases "
|
|
51
|
+
"before settling on an answer. Put nothing but that reasoning between the "
|
|
52
|
+
"tags. After the closing </think> tag, write your final answer for the user."
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def system_prompt_suffix(effort: str) -> str:
|
|
58
|
+
return EFFORT_SYSTEM_SUFFIXES.get(effort, COT_SYSTEM_SUFFIX)
|
|
59
|
+
|
|
60
|
+
# Matches <think>…</think> or <thinking>…</thinking>, case-insensitively, across lines.
|
|
61
|
+
_THINK_RE = re.compile(r"<think(?:ing)?>(.*?)</think(?:ing)?>", re.DOTALL | re.IGNORECASE)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def split_reasoning(text: str) -> tuple[str | None, str]:
|
|
65
|
+
"""Split model output into (reasoning, answer).
|
|
66
|
+
|
|
67
|
+
Returns (None, text) when there is no well-formed reasoning block, so a model
|
|
68
|
+
that ignores the format never has its answer hidden. Whitespace is trimmed.
|
|
69
|
+
"""
|
|
70
|
+
if not text:
|
|
71
|
+
return None, text
|
|
72
|
+
match = _THINK_RE.search(text)
|
|
73
|
+
if match is None:
|
|
74
|
+
return None, text.strip()
|
|
75
|
+
thinking = match.group(1).strip()
|
|
76
|
+
answer = (text[: match.start()] + text[match.end():]).strip()
|
|
77
|
+
return (thinking or None), answer
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Runtime state: in-memory state carried across a turn / session.
|
|
2
|
+
|
|
3
|
+
Holds the active session, the message transcript (mirrored to disk by the
|
|
4
|
+
orchestrator), the active task id, and tool-result history. Permission mode lives
|
|
5
|
+
on the session and is kept in sync with the policy engine by the orchestrator.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from reidx.provider.base import Message
|
|
12
|
+
from reidx.session.models import Session
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RuntimeState:
|
|
17
|
+
session: Session
|
|
18
|
+
messages: list[Message] = field(default_factory=list)
|
|
19
|
+
active_task_id: str | None = None
|
|
20
|
+
turns: int = 0
|
|
21
|
+
last_tool_results: list[dict] = field(default_factory=list)
|
|
22
|
+
last_thinking: str | None = None # chain-of-thought from the last turn (ephemeral)
|
|
23
|
+
# Usage from the most recent provider.chat() call (not summed across turns —
|
|
24
|
+
# each call's prompt_tokens already reflects the whole conversation resent
|
|
25
|
+
# so far, so summing would multiply-count it). 0 for providers that don't
|
|
26
|
+
# report usage (e.g. StubProvider); the UI falls back to a char estimate then.
|
|
27
|
+
last_usage_prompt_tokens: int = 0
|
|
28
|
+
last_usage_completion_tokens: int = 0
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def effective_mode(self):
|
|
32
|
+
return self.session.permission_mode
|