relay-code 0.1.0__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.
agent/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Agent module for the Relay AI coding agent."""
2
+
3
+ from agent.agent import Agent
4
+ from agent.events import AgentEvent, AgentEventType
5
+
6
+ __all__ = ['Agent', 'AgentEvent', 'AgentEventType']
agent/agent.py ADDED
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from typing import AsyncGenerator, Awaitable, Callable
4
+ from agent.session import Session
5
+ from config.config import Config
6
+ from agent.events import AgentEvent, AgentEventType
7
+ from client.response import StreamEventType, ToolCall, ToolResultMessage
8
+ from tools.base import ToolResult
9
+
10
+ class Agent:
11
+ def __init__(
12
+ self,
13
+ config: Config,
14
+ confirmation_handler: Callable[[str, dict], Awaitable[bool]] | None = None,
15
+ ):
16
+ self.config = config
17
+ self.confirmation_handler = confirmation_handler
18
+ self.session: Session | None = Session(self.config)
19
+
20
+ async def run(self, message: str):
21
+ yield AgentEvent.agent_start(message)
22
+ self.session.reset_turn_usage()
23
+ self.session.context_manager.add_user_message(message)
24
+
25
+ final_response: str | None = None
26
+ async for event in self._agentic_loop():
27
+ yield event
28
+
29
+ if event.type == AgentEventType.TEXT_COMPLETE:
30
+ final_response = event.data.get("content")
31
+
32
+ yield AgentEvent.agent_end(final_response, self.session.last_usage)
33
+
34
+ async def _agentic_loop(self) -> AsyncGenerator[AgentEvent, None]:
35
+
36
+ max_turns = self.config.max_turns
37
+
38
+ for i in range(max_turns):
39
+
40
+ self.session.inc_turn()
41
+
42
+ response_text = ""
43
+
44
+ tool_schemas = self.session.tool_registery.get_schemas()
45
+ tool_calls: list[ToolCall] = []
46
+
47
+ async for event in self.session.client.chat_completion(
48
+ self.session.context_manager.get_messages(),
49
+ tools=tool_schemas if tool_schemas else None,
50
+ stream=True
51
+ ):
52
+ if event.type == StreamEventType.TEXT_DELTA:
53
+ if not event.text_delta:
54
+ continue
55
+ content = event.text_delta.content
56
+ response_text += content
57
+ yield AgentEvent.text_delta(content)
58
+ elif event.type == StreamEventType.TOOL_CALL_COMPLETE:
59
+ if event.tool_call:
60
+ tool_calls.append(event.tool_call)
61
+ elif event.type == StreamEventType.MESSAGE_COMPLETE:
62
+ if event.usage:
63
+ self.session.last_usage = event.usage
64
+ self.session.turn_usage += event.usage
65
+ yield AgentEvent.usage(self.session.turn_usage)
66
+ if event.text_delta:
67
+ response_text += event.text_delta.content
68
+ yield AgentEvent.text_delta(event.text_delta.content)
69
+ if event.tool_calls:
70
+ tool_calls.extend(event.tool_calls)
71
+ elif event.type == StreamEventType.ERROR:
72
+ yield AgentEvent.agent_error(event.error or "Unknown error occurred.")
73
+ return
74
+
75
+ self.session.context_manager.add_assistant_message(
76
+ response_text or None,
77
+ [
78
+ {
79
+ 'id': tc.call_id,
80
+ 'type': 'function',
81
+ 'function': {
82
+ 'name': tc.name,
83
+ 'arguments': json.dumps(tc.arguments),
84
+ },
85
+ }
86
+ for tc in tool_calls
87
+ ]
88
+ if tool_calls
89
+ else None
90
+ )
91
+
92
+ if response_text:
93
+ yield AgentEvent.text_complete(response_text)
94
+
95
+ if not tool_calls:
96
+ return
97
+
98
+ tool_call_results: list[ToolResultMessage] = []
99
+
100
+ for tool_call in tool_calls:
101
+ yield AgentEvent.tool_call_start(
102
+ tool_call.call_id,
103
+ tool_call.name,
104
+ tool_call.arguments
105
+ )
106
+
107
+ tool = self.session.tool_registery.get(tool_call.name)
108
+ approved = True
109
+ if tool and tool.is_mutating(tool_call.arguments):
110
+ if self.confirmation_handler is None:
111
+ approved = False
112
+ else:
113
+ approved = await self.confirmation_handler(
114
+ tool_call.name,
115
+ tool_call.arguments,
116
+ )
117
+
118
+ if approved:
119
+ result = await self.session.tool_registery.invoke(
120
+ tool_call.name,
121
+ tool_call.arguments,
122
+ self.config.cwd,
123
+ )
124
+ else:
125
+ result = ToolResult.error_result(
126
+ f"Tool execution denied: {tool_call.name}",
127
+ metadata={"tool_name": tool_call.name, "denied": True},
128
+ )
129
+
130
+ yield AgentEvent.tool_call_complete(
131
+ tool_call.call_id,
132
+ tool_call.name,
133
+ result,
134
+ )
135
+
136
+ tool_call_results.append(
137
+ ToolResultMessage(
138
+ tool_call_id=tool_call.call_id,
139
+ content=result.to_model_output(),
140
+ is_error=not result.success
141
+ )
142
+ )
143
+
144
+ for tool_result in tool_call_results:
145
+ self.session.context_manager.add_tool_result(
146
+ tool_result.tool_call_id,
147
+ tool_result.content,
148
+ )
149
+
150
+ async def __aenter__(self) -> Agent:
151
+ return self
152
+
153
+ async def __aexit__(
154
+ self,
155
+ exc_type,
156
+ exc_val,
157
+ exc_tb
158
+ ) -> None:
159
+
160
+ if self.session and self.session.client:
161
+ await self.session.client.close()
162
+ self.session = None # Close session, everything else will be garbage collected
agent/events.py ADDED
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+ from enum import Enum
3
+ from typing import Any
4
+ from dataclasses import dataclass, field
5
+ from client.response import TokenUsage
6
+ from tools.base import ToolResult
7
+
8
+ class AgentEventType(str, Enum):
9
+
10
+ # Agentic loops, and err
11
+ AGENT_START="agent_start"
12
+ AGENT_END="agent_end"
13
+ AGENT_ERROR="agent_error"
14
+
15
+ # Token accounting, emitted once per completion
16
+ USAGE = "usage"
17
+
18
+ # Text streaming
19
+ TEXT_DELTA = "text_delta"
20
+ TEXT_COMPLETE = "text_complete"
21
+
22
+ # Tool Calls
23
+ TOOL_CALL_START = 'tool_call_start'
24
+ TOOL_CALL_COMPLETE = 'tool_call_complete'
25
+
26
+ @dataclass
27
+ class AgentEvent:
28
+ type: AgentEventType
29
+ data: dict[str, Any] = field(default_factory=dict)
30
+
31
+ @classmethod
32
+ def agent_start(
33
+ cls,
34
+ message: str
35
+ ) -> AgentEvent:
36
+ return cls(
37
+ type=AgentEventType.AGENT_START,
38
+ data={
39
+ "message": message
40
+ }
41
+ )
42
+
43
+ @classmethod
44
+ def agent_end(
45
+ cls,
46
+ response: str | None = None,
47
+ usage: TokenUsage | None = None
48
+ ) -> AgentEvent:
49
+ return cls(
50
+ type=AgentEventType.AGENT_END,
51
+ data={
52
+ "response": response,
53
+ 'usage': usage.__dict__ if usage else None
54
+ },
55
+ )
56
+
57
+ @classmethod
58
+ def usage(
59
+ cls,
60
+ turn_usage: TokenUsage
61
+ ) -> AgentEvent:
62
+ return cls(
63
+ type=AgentEventType.USAGE,
64
+ data={
65
+ 'usage': turn_usage.__dict__,
66
+ }
67
+ )
68
+
69
+ @classmethod
70
+ def agent_error(
71
+ cls,
72
+ error: str,
73
+ details: dict[str, Any] | None = None
74
+ ) -> AgentEvent:
75
+ return cls(
76
+ type=AgentEventType.AGENT_ERROR,
77
+ data={
78
+ "error": error,
79
+ 'details': details or {}
80
+ }
81
+ )
82
+
83
+ @classmethod
84
+ def text_delta(
85
+ cls,
86
+ content: str
87
+ ) -> AgentEvent:
88
+ return cls(
89
+ type=AgentEventType.TEXT_DELTA,
90
+ data={
91
+ "content": content
92
+ }
93
+ )
94
+
95
+ @classmethod
96
+ def text_complete(
97
+ cls,
98
+ content: str
99
+ ) -> AgentEvent:
100
+ return cls(
101
+ type=AgentEventType.TEXT_COMPLETE,
102
+ data={
103
+ "content": content
104
+ }
105
+ )
106
+
107
+ @classmethod
108
+ def tool_call_start(cls, call_id: str, name: str, arguments: dict[str, Any]):
109
+ return cls(
110
+ type=AgentEventType.TOOL_CALL_START,
111
+ data={
112
+ 'call_id': call_id,
113
+ 'name': name,
114
+ 'arguments': arguments
115
+ }
116
+ )
117
+
118
+ @classmethod
119
+ def tool_call_complete(cls, call_id: str, name: str, result: ToolResult):
120
+ return cls(
121
+ type=AgentEventType.TOOL_CALL_COMPLETE,
122
+ data={
123
+ 'call_id': call_id,
124
+ 'name': name,
125
+ 'success': result.success,
126
+ 'output': result.output,
127
+ 'error': result.error,
128
+ 'metadata': result.metadata,
129
+ 'diff': result.diff.create_diff() if result.diff else None,
130
+ 'truncated': result.truncated,
131
+ 'exit_code': result.exit_code,
132
+ }
133
+ )
agent/session.py ADDED
@@ -0,0 +1,71 @@
1
+ import json
2
+ import uuid
3
+
4
+ from client.llm_client import LLMClient
5
+ from client.response import TokenUsage
6
+ from config.config import Config
7
+ from config.loader import get_data_dir
8
+ from context.manager import ContextManager
9
+ from tools.registry import create_default_registery
10
+ from datetime import datetime
11
+
12
+ class Session:
13
+ def __init__(self, config: Config):
14
+ self.config = config
15
+ self.client = LLMClient(
16
+ config=config,
17
+ )
18
+ self.context_manager = ContextManager(
19
+ config=config,
20
+ user_memory=self._load_memory()
21
+ )
22
+ self.tool_registery = create_default_registery(config)
23
+ self.session_id = str(uuid.uuid4()) # Unique identifiers to resume sessions
24
+ self.created_at = datetime.now()
25
+ self.updated_at = datetime.now()
26
+
27
+ self.last_usage: TokenUsage | None = None
28
+
29
+ self.turn_usage = TokenUsage()
30
+
31
+ self._turn_count = 0
32
+
33
+ def _load_memory(self) -> str | None:
34
+
35
+ data_dir = get_data_dir()
36
+
37
+ data_dir.mkdir(
38
+ parents=True,
39
+ exist_ok=True
40
+ )
41
+
42
+ path = data_dir / 'user_memory.json'
43
+
44
+ if not path.exists():
45
+ return None
46
+
47
+ try:
48
+ content = path.read_text(
49
+ encoding='utf-8'
50
+ )
51
+ data = json.loads(content)
52
+ entries = data.get('entries')
53
+ if not entries:
54
+ return None
55
+
56
+ lines = ["User prefrences and notes:"]
57
+ for key, value in entries.items():
58
+ lines.append(f"- {key}: {value}")
59
+
60
+ return "\n".join(lines)
61
+ except Exception:
62
+ return None
63
+
64
+ def reset_turn_usage(self) -> None:
65
+ self.turn_usage = TokenUsage()
66
+
67
+ def inc_turn(self) -> int:
68
+ self._turn_count += 1
69
+ self.updated_at = datetime.now()
70
+
71
+ return self._turn_count
client/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Client module for LLM interactions."""
2
+
3
+ from client.llm_client import LLMClient
4
+ from client.response import (
5
+ StreamEvent,
6
+ StreamEventType,
7
+ TextDelta,
8
+ TokenUsage,
9
+ ToolCall,
10
+ ToolCallDelta,
11
+ ToolResultMessage,
12
+ )
13
+
14
+ __all__ = [
15
+ 'LLMClient',
16
+ 'StreamEvent',
17
+ 'StreamEventType',
18
+ 'TextDelta',
19
+ 'TokenUsage',
20
+ 'ToolCall',
21
+ 'ToolCallDelta',
22
+ 'ToolResultMessage',
23
+ ]
client/llm_client.py ADDED
@@ -0,0 +1,236 @@
1
+ import math
2
+ import asyncio
3
+ from openai import AsyncOpenAI, RateLimitError, APIConnectionError, APIError
4
+ from typing import Any, AsyncGenerator
5
+ from client.response import TextDelta, TokenUsage, StreamEvent, StreamEventType, ToolCallDelta, ToolCall, parse_tool_call_arguments
6
+ from config.config import Config
7
+
8
+
9
+ class LLMClient:
10
+ def __init__(self, config: Config) -> None:
11
+ self._client: AsyncOpenAI | None = None
12
+ self._max_retries: int = 3
13
+ self.config = config
14
+
15
+ def get_client(self) -> AsyncOpenAI:
16
+ if self._client is None:
17
+ # TODO: wire up to your own config system instead of hardcoding
18
+ # DONE !
19
+ self._client = AsyncOpenAI(
20
+ api_key=self.config.api_key,
21
+ base_url=self.config.base_url # "https://openrouter.ai/api/v1",
22
+ )
23
+ return self._client
24
+
25
+ async def close(self) -> None:
26
+ if self._client:
27
+ await self._client.close()
28
+ self._client = None
29
+
30
+ def _build_tools(
31
+ self,
32
+ tools: list[dict[str, Any]]
33
+ ):
34
+ return [
35
+ {
36
+ 'type': 'function',
37
+ 'function': {
38
+ 'name': tool['name'],
39
+ 'description': tool.get('description', ""),
40
+ 'parameters': tool.get('parameters', {
41
+ 'type': 'object',
42
+ 'properties': {}
43
+ })
44
+ },
45
+ }
46
+ for tool in tools
47
+ ]
48
+
49
+ async def chat_completion(
50
+ self,
51
+ messages: list[dict[str, Any]],
52
+ tools: list[dict[str, Any]] | None = None,
53
+ stream: bool = True
54
+ ) -> AsyncGenerator[StreamEvent, None]:
55
+
56
+ client = self.get_client()
57
+
58
+ kwargs = {
59
+ "model": self.config.model_name,
60
+ "messages": messages,
61
+ "stream": stream,
62
+ }
63
+
64
+ # Without this the stream carries no usage chunk at all, and the
65
+ # context gauge on the prompt rule has nothing to show.
66
+ if stream:
67
+ kwargs["stream_options"] = {"include_usage": True}
68
+
69
+ if tools:
70
+ kwargs['tools'] = self._build_tools(tools)
71
+ kwargs['tool_choice'] = 'auto'
72
+
73
+ for attempt in range(self._max_retries + 1):
74
+ emitted = False
75
+ try:
76
+ if stream:
77
+ async for event in self._stream_response(client, kwargs):
78
+ emitted = True
79
+ yield event
80
+ else:
81
+ event = await self._non_stream_response(client, kwargs)
82
+ yield event
83
+ return
84
+ except RateLimitError as e:
85
+ if not emitted and attempt < self._max_retries:
86
+ wait_time = math.pow(2, attempt)
87
+ await asyncio.sleep(wait_time)
88
+ else:
89
+ yield StreamEvent(
90
+ type=StreamEventType.ERROR,
91
+ error=f"Rate limit exceeded: {e}",
92
+ )
93
+ return
94
+ except APIConnectionError as e:
95
+ if not emitted and attempt < self._max_retries:
96
+ wait_time = math.pow(2, attempt)
97
+ await asyncio.sleep(wait_time)
98
+ else:
99
+ yield StreamEvent(
100
+ type=StreamEventType.ERROR,
101
+ error=f"Connection Error exceeded: {e}",
102
+ )
103
+ return
104
+ # No retries here, solely because there's no point in retrying if there is a hardstuck API Error
105
+ except APIError as e:
106
+ yield StreamEvent(
107
+ type=StreamEventType.ERROR,
108
+ error=f"API Error: {e}",
109
+ )
110
+ return
111
+
112
+ async def _stream_response(self, client: AsyncOpenAI, kwargs: dict[str, Any]) -> AsyncGenerator[StreamEvent, None]:
113
+ response = await client.chat.completions.create(**kwargs)
114
+
115
+ finish_reason: str | None = None
116
+ usage: TokenUsage | None = None
117
+ tool_calls: dict[int, dict[str, Any]] = {}
118
+
119
+ async for chunk in response:
120
+ if hasattr(chunk, "usage") and chunk.usage:
121
+ usage = TokenUsage(
122
+ prompt_tokens=chunk.usage.prompt_tokens,
123
+ completion_tokens=chunk.usage.completion_tokens,
124
+ total_tokens=chunk.usage.total_tokens,
125
+ cached_tokens=(
126
+ getattr(chunk.usage.prompt_tokens_details, "cached_tokens", 0)
127
+ if chunk.usage.prompt_tokens_details else 0
128
+ ),
129
+ )
130
+
131
+ if not chunk.choices:
132
+ continue
133
+
134
+ choice = chunk.choices[0]
135
+ delta = choice.delta
136
+
137
+ if choice.finish_reason:
138
+ finish_reason = choice.finish_reason
139
+
140
+ if delta.content:
141
+ yield StreamEvent(
142
+ type=StreamEventType.TEXT_DELTA,
143
+ text_delta=TextDelta(delta.content),
144
+ )
145
+
146
+ if delta.tool_calls:
147
+ for tool_call_delta in delta.tool_calls:
148
+ index = tool_call_delta.index
149
+
150
+ if index not in tool_calls:
151
+ tool_calls[index] = {
152
+ 'id': tool_call_delta.id or "",
153
+ 'name': '',
154
+ 'arguments': '',
155
+ }
156
+
157
+ # NOTE: this used to be nested inside "if index not in tool_calls",
158
+ # which meant argument fragments after the first chunk for a given
159
+ # index were silently dropped. Now runs on every chunk.
160
+ if tool_call_delta.function:
161
+ if tool_call_delta.function.name:
162
+ tool_calls[index]['name'] = tool_call_delta.function.name
163
+ yield StreamEvent(
164
+ type=StreamEventType.TOOL_CALL_START,
165
+ tool_call_delta=ToolCallDelta(
166
+ call_id=tool_calls[index]['id'],
167
+ name=tool_call_delta.function.name,
168
+ )
169
+ )
170
+
171
+ if tool_call_delta.function.arguments:
172
+ tool_calls[index]['arguments'] += tool_call_delta.function.arguments
173
+ yield StreamEvent(
174
+ type=StreamEventType.TOOL_CALL_DELTA,
175
+ tool_call_delta=ToolCallDelta(
176
+ call_id=tool_calls[index]['id'],
177
+ name=tool_calls[index]['name'],
178
+ arguments_delta=tool_call_delta.function.arguments,
179
+ )
180
+ )
181
+
182
+ for index, toolcall in tool_calls.items():
183
+ yield StreamEvent(
184
+ type=StreamEventType.TOOL_CALL_COMPLETE,
185
+ tool_call=ToolCall(
186
+ call_id=toolcall['id'],
187
+ name=toolcall['name'],
188
+ arguments=parse_tool_call_arguments(toolcall['arguments']),
189
+ )
190
+ )
191
+
192
+ yield StreamEvent(
193
+ type=StreamEventType.MESSAGE_COMPLETE,
194
+ finish_reason=finish_reason,
195
+ usage=usage,
196
+ )
197
+
198
+ async def _non_stream_response(self, client: AsyncOpenAI, kwargs: dict[str, Any]) -> StreamEvent:
199
+ response = await client.chat.completions.create(**kwargs)
200
+ choice = response.choices[0] # only interested in first index, first message
201
+ message = choice.message
202
+
203
+ text_delta = None
204
+ if message.content:
205
+ text_delta = TextDelta(content=message.content)
206
+
207
+ tool_calls: list[ToolCall] = []
208
+ if message.tool_calls:
209
+ for toolcall in message.tool_calls:
210
+ tool_calls.append(ToolCall(
211
+ call_id=toolcall.id,
212
+ name=toolcall.function.name,
213
+ arguments=parse_tool_call_arguments(
214
+ toolcall.function.arguments
215
+ )
216
+ ))
217
+
218
+ usage = None
219
+ if response.usage:
220
+ usage = TokenUsage(
221
+ prompt_tokens=response.usage.prompt_tokens,
222
+ completion_tokens=response.usage.completion_tokens,
223
+ total_tokens=response.usage.total_tokens,
224
+ cached_tokens=(
225
+ getattr(response.usage.prompt_tokens_details, "cached_tokens", 0)
226
+ if response.usage.prompt_tokens_details else 0
227
+ ),
228
+ )
229
+
230
+ return StreamEvent(
231
+ type=StreamEventType.MESSAGE_COMPLETE,
232
+ text_delta=text_delta,
233
+ tool_calls=tool_calls,
234
+ finish_reason=choice.finish_reason,
235
+ usage=usage,
236
+ )