rpr-cli 0.1.1__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.
- rpr/__init__.py +1 -0
- rpr/agent/__init__.py +29 -0
- rpr/agent/approval.py +19 -0
- rpr/agent/bootstrap.py +60 -0
- rpr/agent/client.py +115 -0
- rpr/agent/control.py +16 -0
- rpr/agent/mock.py +158 -0
- rpr/agent/runtime.py +291 -0
- rpr/agent/session.py +91 -0
- rpr/agent/tools/__init__.py +10 -0
- rpr/agent/tools/base.py +62 -0
- rpr/agent/tools/mutating.py +47 -0
- rpr/agent/tools/readonly.py +147 -0
- rpr/agent/tools/registry.py +30 -0
- rpr/application/__init__.py +1 -0
- rpr/application/catalog.py +427 -0
- rpr/application/chat_service.py +413 -0
- rpr/application/checks.py +49 -0
- rpr/application/cli_adapter.py +36 -0
- rpr/application/completer.py +163 -0
- rpr/application/conversation_service.py +92 -0
- rpr/application/prompt_service.py +85 -0
- rpr/application/selector.py +240 -0
- rpr/application/shell.py +543 -0
- rpr/checks/__init__.py +0 -0
- rpr/checks/base.py +13 -0
- rpr/checks/instructions.py +89 -0
- rpr/checks/packages.py +619 -0
- rpr/checks/workspace.py +170 -0
- rpr/cli.py +78 -0
- rpr/commands/__init__.py +0 -0
- rpr/commands/add.py +166 -0
- rpr/commands/chat.py +48 -0
- rpr/commands/check.py +53 -0
- rpr/commands/generate/__init__.py +0 -0
- rpr/commands/generate/api.py +228 -0
- rpr/commands/generate/domain.py +383 -0
- rpr/commands/generate/engine.py +148 -0
- rpr/commands/generate/storybook.py +442 -0
- rpr/commands/generate/ui.py +414 -0
- rpr/commands/init.py +822 -0
- rpr/commands/map.py +113 -0
- rpr/commands/settings.py +102 -0
- rpr/commands/sync.py +97 -0
- rpr/context.py +203 -0
- rpr/generators/__init__.py +0 -0
- rpr/generators/base.py +110 -0
- rpr/generators/claude.py +33 -0
- rpr/generators/copilot.py +36 -0
- rpr/generators/cursor.py +38 -0
- rpr/generators/gemini.py +33 -0
- rpr/map/__init__.py +0 -0
- rpr/map/architecture.py +495 -0
- rpr/map/chains.py +317 -0
- rpr/map/classifier.py +170 -0
- rpr/map/coverage.py +200 -0
- rpr/map/dependencies.py +243 -0
- rpr/map/extractor.py +223 -0
- rpr/map/graph.py +318 -0
- rpr/map/output.py +1030 -0
- rpr/map/responsibility.py +345 -0
- rpr/map/topology.py +327 -0
- rpr/map/walker.py +151 -0
- rpr/scaffolds/domain/base_entity.md +30 -0
- rpr/scaffolds/domain/base_repo.md +48 -0
- rpr/scaffolds/domain/container.md +76 -0
- rpr/scaffolds/domain/settings.md +57 -0
- rpr/scaffolds/instructions/all.instructions.md +50 -0
- rpr/scaffolds/instructions/api.instructions.md +42 -0
- rpr/scaffolds/instructions/domain.instructions.md +93 -0
- rpr/scaffolds/instructions/frontend.instructions.md +97 -0
- rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
- rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
- rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
- rpr/scaffolds/instructions/tooling.instructions.md +42 -0
- rpr/scaffolds/js_special_files/fetch.service.md +222 -0
- rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
- rpr/scaffolds/special_files/domain_container.md +76 -0
- rpr/scaffolds/special_files/domain_settings.md +57 -0
- rpr/scaffolds/special_files/dto_util.md +62 -0
- rpr/scaffolds/special_files/encrypted_column.md +98 -0
- rpr/scaffolds/special_files/mapper_util.md +159 -0
- rpr/scaffolds/special_files/partial_update.md +61 -0
- rpr/templates/__init__.py +0 -0
- rpr/templates/registry.py +81 -0
- rpr/ui/__init__.py +0 -0
- rpr/ui/console.py +32 -0
- rpr/ui/markdown.py +59 -0
- rpr/ui/prompt_session.py +430 -0
- rpr/ui/renderers.py +167 -0
- rpr/ui/theme.py +286 -0
- rpr/workspace.py +131 -0
- rpr_cli-0.1.1.dist-info/METADATA +201 -0
- rpr_cli-0.1.1.dist-info/RECORD +97 -0
- rpr_cli-0.1.1.dist-info/WHEEL +4 -0
- rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
- rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
rpr/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
rpr/agent/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from rpr.agent.approval import ApprovalDecision, ApprovalMode
|
|
2
|
+
from rpr.agent.client import (
|
|
3
|
+
AgentClient,
|
|
4
|
+
AgentEvent,
|
|
5
|
+
ConversationMessage,
|
|
6
|
+
TextDeltaEvent,
|
|
7
|
+
ToolCallRequestEvent,
|
|
8
|
+
TurnCompleteEvent,
|
|
9
|
+
)
|
|
10
|
+
from rpr.agent.control import TurnControl
|
|
11
|
+
from rpr.agent.runtime import AgentRuntime, RuntimeCallbacks
|
|
12
|
+
from rpr.agent.session import ChatSession, ToolCallRecord, ToolCallStatus
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AgentClient",
|
|
16
|
+
"AgentEvent",
|
|
17
|
+
"AgentRuntime",
|
|
18
|
+
"ApprovalDecision",
|
|
19
|
+
"ApprovalMode",
|
|
20
|
+
"ChatSession",
|
|
21
|
+
"ConversationMessage",
|
|
22
|
+
"RuntimeCallbacks",
|
|
23
|
+
"TextDeltaEvent",
|
|
24
|
+
"TurnControl",
|
|
25
|
+
"ToolCallRecord",
|
|
26
|
+
"ToolCallRequestEvent",
|
|
27
|
+
"ToolCallStatus",
|
|
28
|
+
"TurnCompleteEvent",
|
|
29
|
+
]
|
rpr/agent/approval.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApprovalMode(StrEnum):
|
|
7
|
+
ASK = "ask"
|
|
8
|
+
AUTO_READONLY = "auto-readonly"
|
|
9
|
+
AUTO_ALL = "auto-all"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApprovalDecision(StrEnum):
|
|
13
|
+
APPROVE = "approve"
|
|
14
|
+
REJECT = "reject"
|
|
15
|
+
ALWAYS_ALLOW = "always-allow"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def requires_approval(*, is_mutating: bool, mode: ApprovalMode) -> bool:
|
|
19
|
+
return is_mutating and mode is not ApprovalMode.AUTO_ALL
|
rpr/agent/bootstrap.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from rpr.agent.approval import ApprovalMode
|
|
8
|
+
from rpr.agent.client import AgentClient
|
|
9
|
+
from rpr.agent.mock import MockAgentClient
|
|
10
|
+
from rpr.agent.runtime import AgentRuntime, RuntimeCallbacks
|
|
11
|
+
from rpr.agent.session import ChatSession
|
|
12
|
+
from rpr.agent.tools import build_default_tool_registry
|
|
13
|
+
from rpr.context import RunContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True, frozen=True)
|
|
17
|
+
class ChatRuntimeBundle:
|
|
18
|
+
ctx: RunContext
|
|
19
|
+
client: AgentClient
|
|
20
|
+
session: ChatSession
|
|
21
|
+
runtime: AgentRuntime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_agent_client(provider: str | None, model: str | None) -> AgentClient:
|
|
25
|
+
provider_name = (provider or "mock").lower()
|
|
26
|
+
if provider_name != "mock":
|
|
27
|
+
raise typer.BadParameter(
|
|
28
|
+
"Only the mock provider is available right now.", param_hint="provider"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return MockAgentClient(model_name=model or "mock-1")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_chat_runtime_bundle(
|
|
35
|
+
*,
|
|
36
|
+
ctx: RunContext,
|
|
37
|
+
client: AgentClient,
|
|
38
|
+
approval_mode: ApprovalMode,
|
|
39
|
+
callbacks: RuntimeCallbacks,
|
|
40
|
+
session: ChatSession | None = None,
|
|
41
|
+
) -> ChatRuntimeBundle:
|
|
42
|
+
active_session = session or ChatSession(
|
|
43
|
+
project_dir=ctx.project_dir, approval_mode=approval_mode
|
|
44
|
+
)
|
|
45
|
+
active_session.project_dir = ctx.project_dir
|
|
46
|
+
active_session.approval_mode = approval_mode
|
|
47
|
+
runtime = AgentRuntime(
|
|
48
|
+
client=client,
|
|
49
|
+
session=active_session,
|
|
50
|
+
tools=build_default_tool_registry(),
|
|
51
|
+
ctx=ctx,
|
|
52
|
+
callbacks=callbacks,
|
|
53
|
+
system_prompt=f"Project root: {ctx.project_dir}",
|
|
54
|
+
)
|
|
55
|
+
return ChatRuntimeBundle(
|
|
56
|
+
ctx=ctx,
|
|
57
|
+
client=client,
|
|
58
|
+
session=active_session,
|
|
59
|
+
runtime=runtime,
|
|
60
|
+
)
|
rpr/agent/client.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from rpr.agent.control import TurnControl
|
|
9
|
+
|
|
10
|
+
type MessageRole = Literal["system", "user", "assistant", "tool"]
|
|
11
|
+
type ToolArguments = dict[str, object]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True, frozen=True)
|
|
15
|
+
class TextPart:
|
|
16
|
+
text: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True, frozen=True)
|
|
20
|
+
class FilePart:
|
|
21
|
+
path: str
|
|
22
|
+
mime_type: str
|
|
23
|
+
data: bytes
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_text(self) -> bool:
|
|
27
|
+
return self.mime_type.startswith("text/") or self.mime_type in {
|
|
28
|
+
"application/json",
|
|
29
|
+
"application/xml",
|
|
30
|
+
"application/yaml",
|
|
31
|
+
"application/x-yaml",
|
|
32
|
+
"application/javascript",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def decoded_text(self) -> str | None:
|
|
36
|
+
if not self.is_text:
|
|
37
|
+
return None
|
|
38
|
+
return self.data.decode("utf-8", errors="replace")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
type MessagePart = TextPart | FilePart
|
|
42
|
+
type MessageParts = tuple[MessagePart, ...]
|
|
43
|
+
type MessageContent = str | MessageParts
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True, frozen=True)
|
|
47
|
+
class ConversationMessage:
|
|
48
|
+
role: MessageRole
|
|
49
|
+
content: MessageContent
|
|
50
|
+
name: str | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def parts(self) -> MessageParts:
|
|
54
|
+
if isinstance(self.content, str):
|
|
55
|
+
return (TextPart(self.content),)
|
|
56
|
+
return self.content
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def text_content(self) -> str:
|
|
60
|
+
if isinstance(self.content, str):
|
|
61
|
+
return self.content
|
|
62
|
+
return "".join(part.text for part in self.content if isinstance(part, TextPart))
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def file_parts(self) -> tuple[FilePart, ...]:
|
|
66
|
+
if isinstance(self.content, str):
|
|
67
|
+
return ()
|
|
68
|
+
return tuple(part for part in self.content if isinstance(part, FilePart))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True, frozen=True)
|
|
72
|
+
class TextDeltaEvent:
|
|
73
|
+
text: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True, frozen=True)
|
|
77
|
+
class ToolCallRequestEvent:
|
|
78
|
+
call_id: str
|
|
79
|
+
tool_name: str
|
|
80
|
+
arguments: ToolArguments
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True, frozen=True)
|
|
84
|
+
class TurnCompleteEvent:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
type AgentEvent = TextDeltaEvent | ToolCallRequestEvent | TurnCompleteEvent
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AgentClient(ABC):
|
|
92
|
+
"""Abstract base class for AI agent clients."""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def provider_name(self) -> str:
|
|
97
|
+
"""Return the name of the provider."""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def model_name(self) -> str:
|
|
103
|
+
"""Return the active model name."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
async def stream(
|
|
108
|
+
self,
|
|
109
|
+
history: list[ConversationMessage],
|
|
110
|
+
*,
|
|
111
|
+
system_prompt: str | None = None,
|
|
112
|
+
control: TurnControl | None = None,
|
|
113
|
+
) -> AsyncGenerator[AgentEvent, None]:
|
|
114
|
+
"""Stream provider-agnostic events for the current turn."""
|
|
115
|
+
...
|
rpr/agent/control.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from threading import Event
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class TurnControl:
|
|
9
|
+
_cancel_requested: Event = field(default_factory=Event)
|
|
10
|
+
|
|
11
|
+
def cancel(self) -> None:
|
|
12
|
+
self._cancel_requested.set()
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def is_cancelled(self) -> bool:
|
|
16
|
+
return self._cancel_requested.is_set()
|
rpr/agent/mock.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import AsyncGenerator, Iterator
|
|
6
|
+
from typing import override
|
|
7
|
+
|
|
8
|
+
from rpr.agent.client import (
|
|
9
|
+
AgentClient,
|
|
10
|
+
ConversationMessage,
|
|
11
|
+
FilePart,
|
|
12
|
+
TextDeltaEvent,
|
|
13
|
+
ToolArguments,
|
|
14
|
+
ToolCallRequestEvent,
|
|
15
|
+
TurnCompleteEvent,
|
|
16
|
+
)
|
|
17
|
+
from rpr.agent.control import TurnControl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MockAgentClient(AgentClient):
|
|
21
|
+
"""A mock agent client for testing and default behavior."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *, model_name: str = "mock-1") -> None:
|
|
24
|
+
self._model_name = model_name
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
@override
|
|
28
|
+
def provider_name(self) -> str:
|
|
29
|
+
return "mock"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
@override
|
|
33
|
+
def model_name(self) -> str:
|
|
34
|
+
return self._model_name
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
async def stream(
|
|
38
|
+
self,
|
|
39
|
+
history: list[ConversationMessage],
|
|
40
|
+
*,
|
|
41
|
+
system_prompt: str | None = None,
|
|
42
|
+
control: TurnControl | None = None,
|
|
43
|
+
) -> AsyncGenerator[
|
|
44
|
+
TextDeltaEvent | ToolCallRequestEvent | TurnCompleteEvent, None
|
|
45
|
+
]:
|
|
46
|
+
del system_prompt
|
|
47
|
+
last_message = history[-1]
|
|
48
|
+
if last_message.role == "tool":
|
|
49
|
+
async for event in self._stream_text(
|
|
50
|
+
f"I used `{last_message.name}` and summarized the result below.\n\n{last_message.text_content}",
|
|
51
|
+
control=control,
|
|
52
|
+
):
|
|
53
|
+
yield event
|
|
54
|
+
if control is None or not control.is_cancelled:
|
|
55
|
+
yield TurnCompleteEvent()
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if last_message.role != "user":
|
|
59
|
+
async for event in self._stream_text(
|
|
60
|
+
"I am ready for the next prompt.", control=control
|
|
61
|
+
):
|
|
62
|
+
yield event
|
|
63
|
+
if control is None or not control.is_cancelled:
|
|
64
|
+
yield TurnCompleteEvent()
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
tool_request = self._parse_tool_request(last_message.text_content)
|
|
68
|
+
if tool_request is not None:
|
|
69
|
+
async for event in self._stream_text(
|
|
70
|
+
"I’m going to inspect the repository before answering.\n\n",
|
|
71
|
+
control=control,
|
|
72
|
+
):
|
|
73
|
+
yield event
|
|
74
|
+
if control is None or not control.is_cancelled:
|
|
75
|
+
yield ToolCallRequestEvent(
|
|
76
|
+
call_id=f"mock-{len(history)}",
|
|
77
|
+
tool_name=tool_request[0],
|
|
78
|
+
arguments=tool_request[1],
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
response_text = self._render_response_text(last_message)
|
|
83
|
+
async for event in self._stream_text(response_text, control=control):
|
|
84
|
+
yield event
|
|
85
|
+
if control is None or not control.is_cancelled:
|
|
86
|
+
yield TurnCompleteEvent()
|
|
87
|
+
|
|
88
|
+
async def _stream_text(
|
|
89
|
+
self,
|
|
90
|
+
text: str,
|
|
91
|
+
*,
|
|
92
|
+
control: TurnControl | None = None,
|
|
93
|
+
) -> AsyncGenerator[TextDeltaEvent, None]:
|
|
94
|
+
for chunk in self._iter_chunks(text):
|
|
95
|
+
if control is not None and control.is_cancelled:
|
|
96
|
+
return
|
|
97
|
+
await asyncio.sleep(0.1)
|
|
98
|
+
yield TextDeltaEvent(text=chunk)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _iter_chunks(text: str) -> Iterator[str]:
|
|
102
|
+
for match in re.finditer(r"\n+|[^\s\n]+\s*", text):
|
|
103
|
+
yield match.group(0)
|
|
104
|
+
|
|
105
|
+
def _parse_tool_request(self, message: str) -> tuple[str, ToolArguments] | None:
|
|
106
|
+
read_match = re.search(
|
|
107
|
+
r"(?:read|show) file (?P<path>\S+)", message, re.IGNORECASE
|
|
108
|
+
)
|
|
109
|
+
if read_match:
|
|
110
|
+
return "read_file", {"path": read_match.group("path")}
|
|
111
|
+
|
|
112
|
+
list_match = re.search(
|
|
113
|
+
r"(?:list|show) (?:directory|files)(?: (?P<path>\S+))?",
|
|
114
|
+
message,
|
|
115
|
+
re.IGNORECASE,
|
|
116
|
+
)
|
|
117
|
+
if list_match:
|
|
118
|
+
return "list_directory", {"path": list_match.group("path") or "."}
|
|
119
|
+
|
|
120
|
+
search_match = re.search(
|
|
121
|
+
r"search(?: for)? (?P<query>.+)", message, re.IGNORECASE
|
|
122
|
+
)
|
|
123
|
+
if search_match:
|
|
124
|
+
return "search_text", {"query": search_match.group("query").strip()}
|
|
125
|
+
|
|
126
|
+
write_match = re.search(
|
|
127
|
+
r"write file (?P<path>\S+)\s*:(?P<content>.+)",
|
|
128
|
+
message,
|
|
129
|
+
re.IGNORECASE | re.DOTALL,
|
|
130
|
+
)
|
|
131
|
+
if write_match:
|
|
132
|
+
return "write_file", {
|
|
133
|
+
"path": write_match.group("path"),
|
|
134
|
+
"content": write_match.group("content").lstrip(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def _render_response_text(self, message: ConversationMessage) -> str:
|
|
140
|
+
response_text = (
|
|
141
|
+
f"I received your message: '{message.text_content}'.\n\n"
|
|
142
|
+
f"This is a mock response from the **Mock Provider** using `{self.model_name}`."
|
|
143
|
+
)
|
|
144
|
+
if not message.file_parts:
|
|
145
|
+
return response_text
|
|
146
|
+
|
|
147
|
+
attachment_lines = [
|
|
148
|
+
self._format_attachment(part) for part in message.file_parts
|
|
149
|
+
]
|
|
150
|
+
return f"{response_text}\n\nAttachments:\n" + "\n".join(attachment_lines)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _format_attachment(part: FilePart) -> str:
|
|
154
|
+
if part.is_text:
|
|
155
|
+
preview = (part.decoded_text() or "").splitlines()
|
|
156
|
+
if preview:
|
|
157
|
+
return f"- {part.path} ({part.mime_type}, {len(part.data)} bytes): {preview[0][:80]}"
|
|
158
|
+
return f"- {part.path} ({part.mime_type}, {len(part.data)} bytes)"
|
rpr/agent/runtime.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import mimetypes
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rpr.agent.approval import ApprovalDecision, requires_approval
|
|
12
|
+
from rpr.agent.client import (
|
|
13
|
+
AgentClient,
|
|
14
|
+
ConversationMessage,
|
|
15
|
+
FilePart,
|
|
16
|
+
MessageContent,
|
|
17
|
+
TextDeltaEvent,
|
|
18
|
+
TextPart,
|
|
19
|
+
ToolCallRequestEvent,
|
|
20
|
+
TurnCompleteEvent,
|
|
21
|
+
)
|
|
22
|
+
from rpr.agent.control import TurnControl
|
|
23
|
+
from rpr.agent.session import ChatSession, ToolCallRecord, ToolCallStatus
|
|
24
|
+
from rpr.agent.tools import ToolRegistry
|
|
25
|
+
from rpr.agent.tools.base import ToolResult, resolve_project_path
|
|
26
|
+
from rpr.context import RunContext
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MAX_ATTACHMENT_BYTES = 64_000
|
|
30
|
+
FILE_REFERENCE_PATTERN = re.compile(r"(?<!\S)@(?P<path>[A-Za-z0-9_./-]+/?)(?=\s|$)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class RuntimeCallbacks:
|
|
35
|
+
on_text_delta: Callable[[str], None]
|
|
36
|
+
on_tool_call: Callable[[ToolCallRecord], None]
|
|
37
|
+
on_tool_result: Callable[[ToolCallRecord, ToolResult], None]
|
|
38
|
+
request_approval: Callable[
|
|
39
|
+
[ToolCallRecord], ApprovalDecision | Awaitable[ApprovalDecision]
|
|
40
|
+
]
|
|
41
|
+
on_attachment_notice: Callable[[str], None] = lambda _message: None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AgentRuntime:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
client: AgentClient,
|
|
49
|
+
session: ChatSession,
|
|
50
|
+
tools: ToolRegistry,
|
|
51
|
+
ctx: RunContext,
|
|
52
|
+
callbacks: RuntimeCallbacks,
|
|
53
|
+
system_prompt: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._client = client
|
|
56
|
+
self._session = session
|
|
57
|
+
self._tools = tools
|
|
58
|
+
self._ctx = ctx
|
|
59
|
+
self._callbacks = callbacks
|
|
60
|
+
self._system_prompt = system_prompt
|
|
61
|
+
|
|
62
|
+
async def execute_turn(
|
|
63
|
+
self, user_input: str, *, control: TurnControl | None = None
|
|
64
|
+
) -> None:
|
|
65
|
+
turn_user_message = self._create_turn_user_message(user_input)
|
|
66
|
+
self._session.add_user_message(user_input)
|
|
67
|
+
history = [*self._session.messages[:-1], turn_user_message]
|
|
68
|
+
|
|
69
|
+
while True:
|
|
70
|
+
if self._is_cancelled(control):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
assistant_content = ""
|
|
74
|
+
requested_tool = False
|
|
75
|
+
stream = self._client.stream(
|
|
76
|
+
history,
|
|
77
|
+
system_prompt=self._system_prompt,
|
|
78
|
+
control=control,
|
|
79
|
+
)
|
|
80
|
+
async for event in stream:
|
|
81
|
+
if self._is_cancelled(control):
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
if isinstance(event, TextDeltaEvent):
|
|
85
|
+
assistant_content += event.text
|
|
86
|
+
self._callbacks.on_text_delta(event.text)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if isinstance(event, ToolCallRequestEvent):
|
|
90
|
+
if assistant_content:
|
|
91
|
+
self._session.add_assistant_message(assistant_content)
|
|
92
|
+
history.append(self._session.messages[-1])
|
|
93
|
+
assistant_content = ""
|
|
94
|
+
|
|
95
|
+
requested_tool = True
|
|
96
|
+
message_count = len(self._session.messages)
|
|
97
|
+
await self._handle_tool_call(event, control=control)
|
|
98
|
+
history.extend(self._session.messages[message_count:])
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if isinstance(event, TurnCompleteEvent):
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if assistant_content:
|
|
105
|
+
self._session.add_assistant_message(assistant_content)
|
|
106
|
+
history.append(self._session.messages[-1])
|
|
107
|
+
|
|
108
|
+
if self._is_cancelled(control) or not requested_tool:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
async def _handle_tool_call(
|
|
112
|
+
self, event: ToolCallRequestEvent, *, control: TurnControl | None = None
|
|
113
|
+
) -> None:
|
|
114
|
+
if self._is_cancelled(control):
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
tool = self._tools.get(event.tool_name)
|
|
118
|
+
is_mutating = False if tool is None else tool.spec.is_mutating
|
|
119
|
+
record = self._session.create_tool_call(
|
|
120
|
+
call_id=event.call_id,
|
|
121
|
+
tool_name=event.tool_name,
|
|
122
|
+
arguments=event.arguments,
|
|
123
|
+
is_mutating=is_mutating,
|
|
124
|
+
)
|
|
125
|
+
self._callbacks.on_tool_call(record)
|
|
126
|
+
|
|
127
|
+
if tool is None:
|
|
128
|
+
result = ToolResult(
|
|
129
|
+
tool_name=event.tool_name,
|
|
130
|
+
success=False,
|
|
131
|
+
output=f"Unknown tool: {event.tool_name}",
|
|
132
|
+
)
|
|
133
|
+
record.status = ToolCallStatus.FAILED
|
|
134
|
+
record.error = result.output
|
|
135
|
+
self._session.add_tool_message(event.tool_name, result.as_message_content())
|
|
136
|
+
self._callbacks.on_tool_result(record, result)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if requires_approval(
|
|
140
|
+
is_mutating=tool.spec.is_mutating, mode=self._session.approval_mode
|
|
141
|
+
):
|
|
142
|
+
if self._is_cancelled(control):
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
decision = self._callbacks.request_approval(record)
|
|
146
|
+
if inspect.isawaitable(decision):
|
|
147
|
+
decision = await decision
|
|
148
|
+
if self._is_cancelled(control):
|
|
149
|
+
result = ToolResult(
|
|
150
|
+
tool_name=tool.spec.name,
|
|
151
|
+
success=False,
|
|
152
|
+
output="Tool execution cancelled.",
|
|
153
|
+
cancelled=True,
|
|
154
|
+
)
|
|
155
|
+
record.status = ToolCallStatus.CANCELLED
|
|
156
|
+
record.error = result.output
|
|
157
|
+
self._session.add_tool_message(
|
|
158
|
+
tool.spec.name, result.as_message_content()
|
|
159
|
+
)
|
|
160
|
+
self._callbacks.on_tool_result(record, result)
|
|
161
|
+
return
|
|
162
|
+
if decision is ApprovalDecision.ALWAYS_ALLOW:
|
|
163
|
+
self._session.enable_auto_all()
|
|
164
|
+
if decision is ApprovalDecision.REJECT:
|
|
165
|
+
result = ToolResult(
|
|
166
|
+
tool_name=tool.spec.name,
|
|
167
|
+
success=False,
|
|
168
|
+
output="Tool execution was rejected by the user.",
|
|
169
|
+
)
|
|
170
|
+
record.status = ToolCallStatus.REJECTED
|
|
171
|
+
record.error = result.output
|
|
172
|
+
self._session.add_tool_message(
|
|
173
|
+
tool.spec.name, result.as_message_content()
|
|
174
|
+
)
|
|
175
|
+
self._callbacks.on_tool_result(record, result)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
record.status = ToolCallStatus.APPROVED
|
|
179
|
+
|
|
180
|
+
if self._is_cancelled(control):
|
|
181
|
+
result = ToolResult(
|
|
182
|
+
tool_name=tool.spec.name,
|
|
183
|
+
success=False,
|
|
184
|
+
output="Tool execution cancelled.",
|
|
185
|
+
cancelled=True,
|
|
186
|
+
)
|
|
187
|
+
record.status = ToolCallStatus.CANCELLED
|
|
188
|
+
record.error = result.output
|
|
189
|
+
self._session.add_tool_message(tool.spec.name, result.as_message_content())
|
|
190
|
+
self._callbacks.on_tool_result(record, result)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
result = await asyncio.to_thread(
|
|
195
|
+
tool.execute, event.arguments, self._ctx, control=control
|
|
196
|
+
)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
result = ToolResult(
|
|
199
|
+
tool_name=event.tool_name, success=False, output=str(exc)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if result.cancelled:
|
|
203
|
+
record.status = ToolCallStatus.CANCELLED
|
|
204
|
+
else:
|
|
205
|
+
record.status = (
|
|
206
|
+
ToolCallStatus.COMPLETED if result.success else ToolCallStatus.FAILED
|
|
207
|
+
)
|
|
208
|
+
if result.success:
|
|
209
|
+
record.output = result.output
|
|
210
|
+
else:
|
|
211
|
+
record.error = result.output
|
|
212
|
+
self._session.add_tool_message(event.tool_name, result.as_message_content())
|
|
213
|
+
self._callbacks.on_tool_result(record, result)
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _is_cancelled(control: TurnControl | None) -> bool:
|
|
217
|
+
return False if control is None else control.is_cancelled
|
|
218
|
+
|
|
219
|
+
def _create_turn_user_message(self, user_input: str) -> ConversationMessage:
|
|
220
|
+
content = self._build_user_message(user_input)
|
|
221
|
+
return ConversationMessage(role="user", content=content)
|
|
222
|
+
|
|
223
|
+
def _build_user_message(self, user_input: str) -> MessageContent:
|
|
224
|
+
attachments: list[FilePart] = []
|
|
225
|
+
seen_paths: set[str] = set()
|
|
226
|
+
|
|
227
|
+
for raw_path in self._extract_paths(user_input):
|
|
228
|
+
try:
|
|
229
|
+
resolved = resolve_project_path(self._ctx.project_dir, raw_path)
|
|
230
|
+
except ValueError:
|
|
231
|
+
self._callbacks.on_attachment_notice(
|
|
232
|
+
f"Ignored @{raw_path}: path must stay within the project root."
|
|
233
|
+
)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if resolved.is_dir():
|
|
237
|
+
self._callbacks.on_attachment_notice(
|
|
238
|
+
f"Ignored @{raw_path}: directories cannot be attached."
|
|
239
|
+
)
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
if not resolved.is_file():
|
|
243
|
+
self._callbacks.on_attachment_notice(
|
|
244
|
+
f"Ignored @{raw_path}: file not found."
|
|
245
|
+
)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
relative_path = resolved.relative_to(self._ctx.project_dir).as_posix()
|
|
249
|
+
if relative_path in seen_paths:
|
|
250
|
+
continue
|
|
251
|
+
seen_paths.add(relative_path)
|
|
252
|
+
|
|
253
|
+
attachment = self._build_file_part(resolved, relative_path)
|
|
254
|
+
if attachment is None:
|
|
255
|
+
continue
|
|
256
|
+
attachments.append(attachment)
|
|
257
|
+
|
|
258
|
+
if not attachments:
|
|
259
|
+
return user_input
|
|
260
|
+
|
|
261
|
+
return (TextPart(user_input), *attachments)
|
|
262
|
+
|
|
263
|
+
def _build_file_part(self, resolved: Path, relative_path: str) -> FilePart | None:
|
|
264
|
+
try:
|
|
265
|
+
data = resolved.read_bytes()
|
|
266
|
+
except OSError as exc:
|
|
267
|
+
self._callbacks.on_attachment_notice(f"Ignored @{relative_path}: {exc}.")
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
truncated = False
|
|
271
|
+
if len(data) > MAX_ATTACHMENT_BYTES:
|
|
272
|
+
data = data[:MAX_ATTACHMENT_BYTES]
|
|
273
|
+
truncated = True
|
|
274
|
+
|
|
275
|
+
mime_type = (
|
|
276
|
+
mimetypes.guess_type(resolved.as_posix(), strict=False)[0]
|
|
277
|
+
or "application/octet-stream"
|
|
278
|
+
)
|
|
279
|
+
suffix = (
|
|
280
|
+
f" ({len(data)} bytes attached, truncated)"
|
|
281
|
+
if truncated
|
|
282
|
+
else f" ({len(data)} bytes attached)"
|
|
283
|
+
)
|
|
284
|
+
self._callbacks.on_attachment_notice(f"Attached @{relative_path}{suffix}.")
|
|
285
|
+
return FilePart(path=relative_path, mime_type=mime_type, data=data)
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _extract_paths(user_input: str) -> tuple[str, ...]:
|
|
289
|
+
return tuple(
|
|
290
|
+
match.group("path") for match in FILE_REFERENCE_PATTERN.finditer(user_input)
|
|
291
|
+
)
|