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 +6 -0
- agent/agent.py +162 -0
- agent/events.py +133 -0
- agent/session.py +71 -0
- client/__init__.py +23 -0
- client/llm_client.py +236 -0
- client/response.py +83 -0
- config/__init__.py +6 -0
- config/config.py +76 -0
- config/credentials.py +54 -0
- config/loader.py +108 -0
- config/oauth.py +137 -0
- context/__init__.py +5 -0
- context/manager.py +92 -0
- main.py +231 -0
- prompts/__init__.py +0 -0
- prompts/system.py +350 -0
- relay_code-0.1.0.dist-info/METADATA +64 -0
- relay_code-0.1.0.dist-info/RECORD +50 -0
- relay_code-0.1.0.dist-info/WHEEL +5 -0
- relay_code-0.1.0.dist-info/entry_points.txt +2 -0
- relay_code-0.1.0.dist-info/licenses/LICENSE +674 -0
- relay_code-0.1.0.dist-info/top_level.txt +9 -0
- tools/__init__.py +14 -0
- tools/base.py +188 -0
- tools/core/__init__.py +44 -0
- tools/core/directories.py +65 -0
- tools/core/edit.py +162 -0
- tools/core/glob.py +92 -0
- tools/core/grep.py +128 -0
- tools/core/read.py +129 -0
- tools/core/shell.py +162 -0
- tools/core/todo.py +135 -0
- tools/core/write.py +76 -0
- tools/memory/__init__.py +0 -0
- tools/memory/memory.py +191 -0
- tools/network/__init__.py +0 -0
- tools/network/fetch.py +72 -0
- tools/network/search.py +72 -0
- tools/registry.py +96 -0
- ui/__init__.py +5 -0
- ui/app.py +949 -0
- ui/format.py +145 -0
- ui/logo.py +46 -0
- ui/renderer.py +580 -0
- ui/theme.py +60 -0
- utils/__init__.py +17 -0
- utils/errors.py +47 -0
- utils/paths.py +44 -0
- utils/text.py +74 -0
client/response.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import Enum
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TextDelta:
|
|
9
|
+
content: str
|
|
10
|
+
|
|
11
|
+
def __str__(self):
|
|
12
|
+
return self.content
|
|
13
|
+
|
|
14
|
+
class StreamEventType(str, Enum):
|
|
15
|
+
TEXT_DELTA = "text_delta"
|
|
16
|
+
MESSAGE_COMPLETE = "message_complete"
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
|
|
19
|
+
TOOL_CALL_START = "tool_call_start"
|
|
20
|
+
TOOL_CALL_DELTA = "tool_call_delta"
|
|
21
|
+
TOOL_CALL_COMPLETE = "tool_call_complete"
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TokenUsage:
|
|
25
|
+
prompt_tokens: int = 0
|
|
26
|
+
completion_tokens: int = 0
|
|
27
|
+
total_tokens: int = 0
|
|
28
|
+
cached_tokens: int = 0
|
|
29
|
+
|
|
30
|
+
def __add__(self, other: TokenUsage):
|
|
31
|
+
return TokenUsage(
|
|
32
|
+
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
|
|
33
|
+
completion_tokens=self.completion_tokens + other.completion_tokens,
|
|
34
|
+
total_tokens=self.total_tokens + other.total_tokens,
|
|
35
|
+
cached_tokens=self.cached_tokens + other.cached_tokens,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ToolCallDelta:
|
|
40
|
+
call_id: str
|
|
41
|
+
name: str | None = None
|
|
42
|
+
arguments_delta: str = ""
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ToolCall:
|
|
46
|
+
call_id: str
|
|
47
|
+
name: str | None = None
|
|
48
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class StreamEvent:
|
|
52
|
+
type: StreamEventType
|
|
53
|
+
text_delta: TextDelta | None = None
|
|
54
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
55
|
+
error: str | None = None
|
|
56
|
+
finish_reason: str | None = None
|
|
57
|
+
tool_call_delta: ToolCallDelta | None = None
|
|
58
|
+
tool_call: ToolCall | None = None
|
|
59
|
+
usage: TokenUsage | None = None
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ToolResultMessage:
|
|
63
|
+
tool_call_id: str
|
|
64
|
+
content: str
|
|
65
|
+
is_error: bool = False
|
|
66
|
+
|
|
67
|
+
def to_openai_message(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
'role': 'tool',
|
|
70
|
+
'tool_call_id': self.tool_call_id,
|
|
71
|
+
'content': self.content
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def parse_tool_call_arguments(arguments_str: str) -> dict[str, Any]:
|
|
75
|
+
if not arguments_str:
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(arguments_str)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
return {
|
|
82
|
+
'raw_arguments': arguments_str
|
|
83
|
+
}
|
config/__init__.py
ADDED
config/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from config.credentials import load_credentials
|
|
6
|
+
|
|
7
|
+
class ModelConfig(BaseModel):
|
|
8
|
+
|
|
9
|
+
name: str = "tencent/hy3:free"
|
|
10
|
+
|
|
11
|
+
temperature: float = Field(
|
|
12
|
+
default=1,
|
|
13
|
+
ge=0.0,
|
|
14
|
+
le=2.0
|
|
15
|
+
) # clarity of the model
|
|
16
|
+
|
|
17
|
+
context_window: int = 256_000
|
|
18
|
+
|
|
19
|
+
class ShellEnvironmentConfig(BaseModel):
|
|
20
|
+
ignore_default_excludes: bool = False # for filtering keys, secrets
|
|
21
|
+
exclude_patterns: list[str] = Field(
|
|
22
|
+
default_factory=lambda: ["*KEY*", "*TOKEN*", "*SECRET*"]
|
|
23
|
+
)
|
|
24
|
+
set_vars: dict[str, str] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
class Config(BaseModel):
|
|
27
|
+
|
|
28
|
+
model: ModelConfig = Field(default_factory=ModelConfig)
|
|
29
|
+
cwd: Path = Field(default_factory=Path.cwd)
|
|
30
|
+
shell_environment: ShellEnvironmentConfig = Field(
|
|
31
|
+
default_factory=ShellEnvironmentConfig
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
max_turns: int = 100
|
|
35
|
+
max_tool_output_tokens: int = 50_000
|
|
36
|
+
|
|
37
|
+
developer_instructions: str | None = None
|
|
38
|
+
user_instructions: str | None = None
|
|
39
|
+
debug: bool = False
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def api_key(self) -> str | None:
|
|
43
|
+
# Env var wins (handy for CI / one-off overrides); otherwise fall
|
|
44
|
+
# back to whatever `relay login` saved.
|
|
45
|
+
return os.environ.get("API_KEY") or load_credentials().get("api_key")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def base_url(self) -> str | None:
|
|
49
|
+
return os.environ.get("BASE_URL") or load_credentials().get("base_url")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def model_name(self) -> str:
|
|
53
|
+
return self.model.name
|
|
54
|
+
|
|
55
|
+
@model_name.setter
|
|
56
|
+
def model_name(self, value: str) -> None:
|
|
57
|
+
self.model.name = value
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def temperature(self) -> float:
|
|
61
|
+
return self.model.temperature
|
|
62
|
+
|
|
63
|
+
@temperature.setter
|
|
64
|
+
def temperature(self, value: float) -> None:
|
|
65
|
+
self.model.temperature = value
|
|
66
|
+
|
|
67
|
+
def validate(self) -> list[str]:
|
|
68
|
+
errors: list[str] = []
|
|
69
|
+
|
|
70
|
+
if not self.api_key:
|
|
71
|
+
errors.append("No API key was found. Solution: run `relay login` (or set the API_KEY environment variable)")
|
|
72
|
+
|
|
73
|
+
if not self.cwd.exists():
|
|
74
|
+
errors.append(f"Working directory does not exist: {self.cwd}")
|
|
75
|
+
|
|
76
|
+
return errors
|
config/credentials.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from platformdirs import user_config_dir
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import tomllib
|
|
8
|
+
except ModuleNotFoundError:
|
|
9
|
+
import tomli as tomllib
|
|
10
|
+
|
|
11
|
+
CREDENTIALS_FILE = "credentials.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_credentials_path() -> Path:
|
|
15
|
+
return Path(user_config_dir("relay")) / CREDENTIALS_FILE
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_credentials() -> dict[str, str]:
|
|
19
|
+
path = get_credentials_path()
|
|
20
|
+
if not path.is_file():
|
|
21
|
+
return {}
|
|
22
|
+
try:
|
|
23
|
+
with open(path, "rb") as f:
|
|
24
|
+
return tomllib.load(f)
|
|
25
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _toml_escape(value: str) -> str:
|
|
30
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_credentials(api_key: str, base_url: str | None = None) -> Path:
|
|
34
|
+
path = get_credentials_path()
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
lines = ["# relay credentials, written by `relay login`. Do not commit.\n"]
|
|
38
|
+
lines.append(f'api_key = "{_toml_escape(api_key)}"\n')
|
|
39
|
+
if base_url:
|
|
40
|
+
lines.append(f'base_url = "{_toml_escape(base_url)}"\n')
|
|
41
|
+
|
|
42
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
44
|
+
f.writelines(lines)
|
|
45
|
+
os.chmod(path, 0o600)
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def clear_credentials() -> bool:
|
|
50
|
+
path = get_credentials_path()
|
|
51
|
+
if path.is_file():
|
|
52
|
+
path.unlink()
|
|
53
|
+
return True
|
|
54
|
+
return False
|
config/loader.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from config.config import Config
|
|
3
|
+
from platformdirs import user_config_dir
|
|
4
|
+
try:
|
|
5
|
+
import tomllib
|
|
6
|
+
except ModuleNotFoundError:
|
|
7
|
+
import tomli as tomllib
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from utils.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
CONFIG_FILE = 'config.toml'
|
|
16
|
+
AGENT_MD_FILE = 'AGENTS.md'
|
|
17
|
+
|
|
18
|
+
def get_config_dir() -> Path:
|
|
19
|
+
return Path(user_config_dir('relay'))
|
|
20
|
+
|
|
21
|
+
def get_data_dir() -> Path:
|
|
22
|
+
return Path(user_config_dir('relay'))
|
|
23
|
+
|
|
24
|
+
def get_system_config_path() -> Path:
|
|
25
|
+
return get_config_dir() / CONFIG_FILE
|
|
26
|
+
|
|
27
|
+
def _parse_toml(path: Path):
|
|
28
|
+
try:
|
|
29
|
+
with open(path, 'rb') as f:
|
|
30
|
+
return tomllib.load(f)
|
|
31
|
+
except tomllib.TOMLDecodeError as e:
|
|
32
|
+
raise ConfigError(f"Invalid TOML in {path}: {e}", config_file=str(path)) from e
|
|
33
|
+
except (OSError, IOError) as e:
|
|
34
|
+
raise ConfigError(f"Failed to read config file: {path}: {e}", config_file=str(path)) from e
|
|
35
|
+
|
|
36
|
+
def _get_project_config(cwd: Path) -> Path:
|
|
37
|
+
|
|
38
|
+
current = cwd.resolve()
|
|
39
|
+
agent_dir = current / '.relay'
|
|
40
|
+
|
|
41
|
+
if agent_dir.is_dir():
|
|
42
|
+
config_file = agent_dir / CONFIG_FILE
|
|
43
|
+
if config_file.is_file():
|
|
44
|
+
return config_file
|
|
45
|
+
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def _get_agent_md(cwd: Path) -> Path:
|
|
49
|
+
|
|
50
|
+
current = cwd.resolve()
|
|
51
|
+
|
|
52
|
+
if current.is_dir():
|
|
53
|
+
agent_md_file = current / AGENT_MD_FILE
|
|
54
|
+
if agent_md_file.is_file():
|
|
55
|
+
return agent_md_file.read_text(encoding='utf-8')
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def _merge_dicts(
|
|
60
|
+
base: dict[str, Any],
|
|
61
|
+
override: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
result = base.copy()
|
|
63
|
+
for key, value in override.items():
|
|
64
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
65
|
+
result[key] = _merge_dicts(result[key], value)
|
|
66
|
+
else:
|
|
67
|
+
result[key] = value
|
|
68
|
+
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
def load_config(cwd: Path | None) -> Config:
|
|
72
|
+
cwd = cwd or Path.cwd()
|
|
73
|
+
|
|
74
|
+
system_path = get_system_config_path()
|
|
75
|
+
|
|
76
|
+
config_dict: dict[str, Any] = {}
|
|
77
|
+
|
|
78
|
+
if system_path.is_file():
|
|
79
|
+
try:
|
|
80
|
+
config_dict = _merge_dicts(config_dict, _parse_toml(system_path))
|
|
81
|
+
except ConfigError:
|
|
82
|
+
logger.exception(f"Invalid system config: {system_path}")
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
project_path = _get_project_config(cwd)
|
|
86
|
+
if project_path:
|
|
87
|
+
try:
|
|
88
|
+
project_config_dict = _parse_toml(project_path)
|
|
89
|
+
config_dict = _merge_dicts(config_dict, project_config_dict)
|
|
90
|
+
except ConfigError:
|
|
91
|
+
logger.exception(f"Invalid project config: {project_path}")
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
if "cwd" not in config_dict:
|
|
95
|
+
config_dict["cwd"] = cwd
|
|
96
|
+
|
|
97
|
+
if "developer_instructions" not in config_dict:
|
|
98
|
+
agent_md_content = _get_agent_md(cwd)
|
|
99
|
+
|
|
100
|
+
if agent_md_content:
|
|
101
|
+
config_dict['developer_instructions'] = agent_md_content
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
config = Config(**config_dict)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise ConfigError(f"Invalid config: {e}") from e
|
|
107
|
+
|
|
108
|
+
return config
|
config/oauth.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import secrets
|
|
4
|
+
import threading
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import webbrowser
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
AUTH_URL = "https://openrouter.ai/auth"
|
|
12
|
+
# Token exchange lives under the API base; keep it in step with base_url.
|
|
13
|
+
KEYS_PATH = "/auth/keys"
|
|
14
|
+
|
|
15
|
+
# How long we'll wait for the user to finish authorizing in the browser.
|
|
16
|
+
CALLBACK_TIMEOUT_SECONDS = 300
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OAuthError(Exception):
|
|
20
|
+
"""Raised when the OAuth flow can't be completed."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _generate_pkce_pair() -> tuple[str, str]:
|
|
24
|
+
"""Return (code_verifier, code_challenge) for the S256 method."""
|
|
25
|
+
# 32 random bytes -> 43-char base64url string, within the RFC 7636
|
|
26
|
+
# 43..128 length window.
|
|
27
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
28
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
29
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
30
|
+
return verifier, challenge
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
34
|
+
"""Single-shot handler that captures the `code` from the redirect."""
|
|
35
|
+
|
|
36
|
+
# Set by the server factory below.
|
|
37
|
+
result: dict[str, str] = {}
|
|
38
|
+
|
|
39
|
+
def do_GET(self) -> None: # noqa: N802 (http.server naming)
|
|
40
|
+
query = urllib.parse.urlparse(self.path).query
|
|
41
|
+
params = urllib.parse.parse_qs(query)
|
|
42
|
+
code = params.get("code", [None])[0]
|
|
43
|
+
|
|
44
|
+
if code:
|
|
45
|
+
type(self).result["code"] = code
|
|
46
|
+
body = "<h2>relay is now logged in.</h2><p>You can close this tab.</p>"
|
|
47
|
+
else:
|
|
48
|
+
type(self).result["error"] = params.get("error", ["unknown error"])[0]
|
|
49
|
+
body = "<h2>Login failed.</h2><p>You can close this tab and try again.</p>"
|
|
50
|
+
|
|
51
|
+
self.send_response(200)
|
|
52
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
53
|
+
self.end_headers()
|
|
54
|
+
self.wfile.write(body.encode())
|
|
55
|
+
|
|
56
|
+
def log_message(self, *_args) -> None:
|
|
57
|
+
# Silence the default stderr request logging; it just clutters the CLI.
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _wait_for_code(server: HTTPServer, result: dict[str, str]) -> str:
|
|
62
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
63
|
+
thread.start()
|
|
64
|
+
try:
|
|
65
|
+
# Poll rather than block forever so a user who never finishes in the
|
|
66
|
+
# browser eventually gets a clear timeout instead of a hung CLI.
|
|
67
|
+
deadline = threading.Event()
|
|
68
|
+
waited = 0.0
|
|
69
|
+
while not (result.get("code") or result.get("error")):
|
|
70
|
+
if deadline.wait(0.25):
|
|
71
|
+
break
|
|
72
|
+
waited += 0.25
|
|
73
|
+
if waited >= CALLBACK_TIMEOUT_SECONDS:
|
|
74
|
+
raise OAuthError("Timed out waiting for browser authorization.")
|
|
75
|
+
finally:
|
|
76
|
+
server.shutdown()
|
|
77
|
+
thread.join(timeout=1)
|
|
78
|
+
|
|
79
|
+
if result.get("error"):
|
|
80
|
+
raise OAuthError(f"Authorization was denied: {result['error']}")
|
|
81
|
+
return result["code"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _exchange_code(base_url: str, code: str, verifier: str) -> str:
|
|
85
|
+
keys_url = base_url.rstrip("/") + KEYS_PATH
|
|
86
|
+
try:
|
|
87
|
+
resp = httpx.post(
|
|
88
|
+
keys_url,
|
|
89
|
+
json={
|
|
90
|
+
"code": code,
|
|
91
|
+
"code_verifier": verifier,
|
|
92
|
+
"code_challenge_method": "S256",
|
|
93
|
+
},
|
|
94
|
+
timeout=30,
|
|
95
|
+
)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
key = resp.json().get("key")
|
|
98
|
+
except httpx.HTTPError as e:
|
|
99
|
+
raise OAuthError(f"Failed to exchange code for a key: {e}") from e
|
|
100
|
+
|
|
101
|
+
if not key:
|
|
102
|
+
raise OAuthError("OpenRouter did not return an API key.")
|
|
103
|
+
return key
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def login_with_oauth(base_url: str, open_browser=webbrowser.open) -> str:
|
|
107
|
+
verifier, challenge = _generate_pkce_pair()
|
|
108
|
+
|
|
109
|
+
# Port 0 -> the OS hands us a free ephemeral port. OpenRouter accepts any
|
|
110
|
+
# localhost callback, so nothing needs pre-registering.
|
|
111
|
+
handler = type("_Handler", (_CallbackHandler,), {"result": {}})
|
|
112
|
+
server = HTTPServer(("127.0.0.1", 0), handler)
|
|
113
|
+
port = server.server_address[1]
|
|
114
|
+
callback_url = f"http://localhost:{port}"
|
|
115
|
+
|
|
116
|
+
params = urllib.parse.urlencode(
|
|
117
|
+
{
|
|
118
|
+
"callback_url": callback_url,
|
|
119
|
+
"code_challenge": challenge,
|
|
120
|
+
"code_challenge_method": "S256",
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
auth_url = f"{AUTH_URL}?{params}"
|
|
124
|
+
|
|
125
|
+
opened = False
|
|
126
|
+
try:
|
|
127
|
+
opened = open_browser(auth_url)
|
|
128
|
+
except webbrowser.Error:
|
|
129
|
+
opened = False
|
|
130
|
+
|
|
131
|
+
if not opened:
|
|
132
|
+
# Headless / no default browser: let the user open it themselves.
|
|
133
|
+
print("Open this URL in your browser to authorize relay:")
|
|
134
|
+
print(f" {auth_url}")
|
|
135
|
+
|
|
136
|
+
code = _wait_for_code(server, handler.result)
|
|
137
|
+
return _exchange_code(base_url, code, verifier)
|
context/__init__.py
ADDED
context/manager.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from config.config import Config
|
|
2
|
+
from prompts.system import get_system_prompt
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from utils.text import count_tokens
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class MessageItem:
|
|
9
|
+
role: str
|
|
10
|
+
content: str
|
|
11
|
+
token_count: int | None = None
|
|
12
|
+
tool_call_id: str | None = None
|
|
13
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> dict[str, Any]:
|
|
16
|
+
result: dict[str, Any] = {
|
|
17
|
+
"role": self.role
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if self.tool_call_id:
|
|
21
|
+
result['tool_call_id'] = self.tool_call_id
|
|
22
|
+
|
|
23
|
+
if self.tool_calls:
|
|
24
|
+
result['tool_calls'] = self.tool_calls
|
|
25
|
+
|
|
26
|
+
if self.content:
|
|
27
|
+
result['content'] = self.content
|
|
28
|
+
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
class ContextManager:
|
|
32
|
+
def __init__(self, config: Config, user_memory: str | None ) -> None:
|
|
33
|
+
self._system_prompt = get_system_prompt(config, user_memory)
|
|
34
|
+
self.config = config
|
|
35
|
+
self._model_name = self.config.model_name
|
|
36
|
+
self._messages: list[MessageItem] = []
|
|
37
|
+
|
|
38
|
+
def add_user_message(self, content):
|
|
39
|
+
item = MessageItem(
|
|
40
|
+
role = 'user',
|
|
41
|
+
content = content,
|
|
42
|
+
token_count=count_tokens(content, self._model_name)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self._messages.append(item)
|
|
46
|
+
|
|
47
|
+
def add_assistant_message(
|
|
48
|
+
self,
|
|
49
|
+
content: str,
|
|
50
|
+
tool_calls: list[dict[str, Any]] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
item = MessageItem(
|
|
53
|
+
role = 'assistant',
|
|
54
|
+
content = content or "",
|
|
55
|
+
token_count=count_tokens(
|
|
56
|
+
content or "",
|
|
57
|
+
self._model_name
|
|
58
|
+
),
|
|
59
|
+
tool_calls=tool_calls or []
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self._messages.append(item)
|
|
63
|
+
|
|
64
|
+
def get_messages(self) -> list[dict[str, Any]]:
|
|
65
|
+
messages = []
|
|
66
|
+
|
|
67
|
+
if self._system_prompt:
|
|
68
|
+
messages.append(
|
|
69
|
+
{
|
|
70
|
+
'role': 'system',
|
|
71
|
+
'content': self._system_prompt
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
for item in self._messages:
|
|
76
|
+
messages.append(item.to_dict())
|
|
77
|
+
|
|
78
|
+
return messages
|
|
79
|
+
|
|
80
|
+
def add_tool_result(
|
|
81
|
+
self,
|
|
82
|
+
tool_call_id: str,
|
|
83
|
+
content: str
|
|
84
|
+
) -> None:
|
|
85
|
+
item = MessageItem(
|
|
86
|
+
role='tool',
|
|
87
|
+
content=content,
|
|
88
|
+
tool_call_id=tool_call_id,
|
|
89
|
+
token_count=count_tokens(content, self._model_name)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self._messages.append(item)
|