codenaut 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 +1 -0
- agent/agent.py +236 -0
- agent/events.py +145 -0
- agent/persistence.py +123 -0
- agent/session.py +99 -0
- client/__init__.py +1 -0
- client/llm_client.py +266 -0
- client/response.py +88 -0
- codenaut-0.1.0.dist-info/METADATA +199 -0
- codenaut-0.1.0.dist-info/RECORD +63 -0
- codenaut-0.1.0.dist-info/WHEEL +5 -0
- codenaut-0.1.0.dist-info/entry_points.txt +3 -0
- codenaut-0.1.0.dist-info/licenses/LICENSE +21 -0
- codenaut-0.1.0.dist-info/top_level.txt +12 -0
- config/__init__.py +1 -0
- config/config.py +173 -0
- config/loader.py +173 -0
- context/__init__.py +1 -0
- context/compaction.py +96 -0
- context/loop_detector.py +49 -0
- context/manager.py +257 -0
- hooks/__init__.py +1 -0
- hooks/hook_system.py +140 -0
- main.py +422 -0
- prompts/__init__.py +1 -0
- prompts/system.py +380 -0
- safety/__init__.py +1 -0
- safety/approval.py +157 -0
- skills/__init__.py +9 -0
- skills/discovery.py +97 -0
- skills/models.py +14 -0
- skills/registry.py +56 -0
- tools/__init__.py +1 -0
- tools/base.py +200 -0
- tools/builtin/__init__.py +67 -0
- tools/builtin/apply_patch.py +388 -0
- tools/builtin/edit_file.py +211 -0
- tools/builtin/glob.py +80 -0
- tools/builtin/grep.py +117 -0
- tools/builtin/list_dir.py +60 -0
- tools/builtin/memory.py +115 -0
- tools/builtin/read_file.py +133 -0
- tools/builtin/shell.py +160 -0
- tools/builtin/tasks.py +336 -0
- tools/builtin/web_fetch.py +55 -0
- tools/builtin/web_search.py +60 -0
- tools/builtin/write_file.py +111 -0
- tools/discovery.py +70 -0
- tools/mcp/__init__.py +1 -0
- tools/mcp/client.py +127 -0
- tools/mcp/mcp_manager.py +83 -0
- tools/mcp/mcp_tool.py +51 -0
- tools/registry.py +156 -0
- tools/subagents.py +148 -0
- ui/__init__.py +12 -0
- ui/completer.py +160 -0
- ui/prompt.py +65 -0
- ui/tui.py +669 -0
- utils/__init__.py +1 -0
- utils/errors.py +49 -0
- utils/file_tags.py +83 -0
- utils/paths.py +40 -0
- utils/text.py +79 -0
config/config.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModelConfig(BaseModel):
|
|
12
|
+
name: str = "openai/gpt-oss-120b"
|
|
13
|
+
temperature: float = Field(default=1, ge=0.0, le=2.0)
|
|
14
|
+
context_window: int = 512_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ShellEnvironmentPolicy(BaseModel):
|
|
18
|
+
ignore_default_excludes: bool = False
|
|
19
|
+
exclude_patterns: list[str] = Field(
|
|
20
|
+
default_factory=lambda: ["*KEY*", "*TOKEN*", "*SECRET*"]
|
|
21
|
+
)
|
|
22
|
+
set_vars: dict[str, str] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCPServerConfig(BaseModel):
|
|
26
|
+
enabled: bool = True
|
|
27
|
+
startup_timeout_sec: float = 10
|
|
28
|
+
|
|
29
|
+
command: str | None = None
|
|
30
|
+
args: list[str] = Field(default_factory=list)
|
|
31
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
32
|
+
cwd: Path | None = None
|
|
33
|
+
url: str | None = None
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="after")
|
|
36
|
+
def validate_transport(self) -> MCPServerConfig:
|
|
37
|
+
has_command = self.command is not None
|
|
38
|
+
has_url = self.url is not None
|
|
39
|
+
|
|
40
|
+
if not has_command and not has_url:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"MCP Server must have either 'command' (stdio) or 'url' (http/sse)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if has_command and has_url:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"MCP Server cannot have both 'command' (stdio) and 'url' (http/sse)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ApprovalPolicy(str, Enum):
|
|
54
|
+
ON_REQUEST = "on-request"
|
|
55
|
+
ON_FAILURE = "on-failure"
|
|
56
|
+
AUTO = "auto"
|
|
57
|
+
AUTO_EDIT = "auto-editt"
|
|
58
|
+
NEVER = "never"
|
|
59
|
+
YOLO = "yolo"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HookTrigger(str, Enum):
|
|
63
|
+
BEFORE_AGENT = "before_agent"
|
|
64
|
+
AFTER_AGENT = "after_agent"
|
|
65
|
+
BEFORE_TOOL = "before_tool"
|
|
66
|
+
AFTER_TOOL = "after_tool"
|
|
67
|
+
ON_ERROR = "on_error"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HookConfig(BaseModel):
|
|
71
|
+
name: str
|
|
72
|
+
trigger: HookTrigger
|
|
73
|
+
command: str | None = None
|
|
74
|
+
script: str | None = None
|
|
75
|
+
timeout_sec: float = 30
|
|
76
|
+
enabled: bool = True
|
|
77
|
+
|
|
78
|
+
@model_validator(mode="after")
|
|
79
|
+
def validate_hook(self) -> HookConfig:
|
|
80
|
+
if not self.command and not self.script:
|
|
81
|
+
raise ValueError("Hook must either have 'command' or 'script'")
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Config(BaseModel):
|
|
86
|
+
model: ModelConfig = Field(default_factory=ModelConfig)
|
|
87
|
+
cwd: Path = Field(default_factory=Path.cwd)
|
|
88
|
+
shell_environment: ShellEnvironmentPolicy = Field(
|
|
89
|
+
default_factory=ShellEnvironmentPolicy
|
|
90
|
+
)
|
|
91
|
+
hooks_enabled: bool = True
|
|
92
|
+
hooks: list[HookConfig] = Field(default_factory=list)
|
|
93
|
+
approval: ApprovalPolicy = ApprovalPolicy.ON_REQUEST
|
|
94
|
+
max_turns: int = 100
|
|
95
|
+
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
|
96
|
+
api_key_value: str | None = Field(
|
|
97
|
+
default=None,
|
|
98
|
+
alias="api_key",
|
|
99
|
+
exclude=True,
|
|
100
|
+
repr=False,
|
|
101
|
+
)
|
|
102
|
+
base_url_value: str | None = Field(
|
|
103
|
+
default=None,
|
|
104
|
+
alias="base_url",
|
|
105
|
+
exclude=True,
|
|
106
|
+
repr=False,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
allowed_tools: list[str] | None = Field(
|
|
110
|
+
None,
|
|
111
|
+
description="If set, only these tools will be available to the agent",
|
|
112
|
+
)
|
|
113
|
+
skills_enabled: bool = True
|
|
114
|
+
extra_skill_dirs: list[Path] = Field(default_factory=list)
|
|
115
|
+
allowed_skills: list[str] | None = Field(
|
|
116
|
+
None,
|
|
117
|
+
description="If set, only these skills will be available to the agent",
|
|
118
|
+
)
|
|
119
|
+
always_loaded_skills: list[str] = Field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
developer_instructions: str | None = None
|
|
122
|
+
user_instructions: str | None = None
|
|
123
|
+
|
|
124
|
+
debug: bool = False
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def api_key(self) -> str | None:
|
|
128
|
+
return self.api_key_value or os.environ.get("API_KEY")
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def base_url(self) -> str | None:
|
|
132
|
+
return self.base_url_value or os.environ.get("BASE_URL")
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def model_name(self) -> str:
|
|
136
|
+
return self.model.name
|
|
137
|
+
|
|
138
|
+
@model_name.setter
|
|
139
|
+
def model_name(self, value: str) -> None:
|
|
140
|
+
self.model.name = value
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def temperature(self) -> float:
|
|
144
|
+
return self.model.temperature
|
|
145
|
+
|
|
146
|
+
@temperature.setter
|
|
147
|
+
def temperature(self, value: float) -> None:
|
|
148
|
+
self.model.temperature = value
|
|
149
|
+
|
|
150
|
+
def set_credentials(self, api_key: str, base_url: str | None = None) -> None:
|
|
151
|
+
self.api_key_value = api_key
|
|
152
|
+
self.base_url_value = base_url or None
|
|
153
|
+
|
|
154
|
+
def validate(self) -> list[str]:
|
|
155
|
+
errors: list[str] = []
|
|
156
|
+
|
|
157
|
+
if not self.api_key:
|
|
158
|
+
errors.append(
|
|
159
|
+
"No API key found. Run `code-it --configure` or set API_KEY."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not self.cwd.exists():
|
|
163
|
+
errors.append(f"Working directory does not exist: {self.cwd}")
|
|
164
|
+
|
|
165
|
+
return errors
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Any]:
|
|
168
|
+
data = self.model_dump(mode="json", by_alias=True)
|
|
169
|
+
if self.api_key_value:
|
|
170
|
+
data["api_key"] = self.api_key_value
|
|
171
|
+
if self.base_url_value:
|
|
172
|
+
data["base_url"] = self.base_url_value
|
|
173
|
+
return data
|
config/loader.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from platformdirs import user_config_dir, user_data_dir
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib as tomli
|
|
10
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
11
|
+
import tomli
|
|
12
|
+
|
|
13
|
+
from config.config import Config
|
|
14
|
+
from utils.errors import ConfigError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
APP_NAME = "code-it"
|
|
19
|
+
CONFIG_FILE_NAME = "config.toml"
|
|
20
|
+
AGENT_MD_FILE = "AGENT.MD"
|
|
21
|
+
PROJECT_CONFIG_DIR_NAME = ".ai-agent"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_config_dir() -> Path:
|
|
25
|
+
return Path(user_config_dir(APP_NAME))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_data_dir() -> Path:
|
|
29
|
+
return Path(user_data_dir(APP_NAME))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_config_dir() -> Path:
|
|
33
|
+
config_dir = get_config_dir()
|
|
34
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return config_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_system_config_path() -> Path:
|
|
39
|
+
return get_config_dir() / CONFIG_FILE_NAME
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_toml(path: Path) -> dict[str, Any]:
|
|
43
|
+
try:
|
|
44
|
+
with open(path, "rb") as file_obj:
|
|
45
|
+
data = tomli.load(file_obj)
|
|
46
|
+
return data if isinstance(data, dict) else {}
|
|
47
|
+
except tomli.TOMLDecodeError as error:
|
|
48
|
+
raise ConfigError(
|
|
49
|
+
f"Invalid TOML in {path}: {error}",
|
|
50
|
+
config_file=str(path),
|
|
51
|
+
) from error
|
|
52
|
+
except (OSError, IOError) as error:
|
|
53
|
+
raise ConfigError(
|
|
54
|
+
f"Failed to read config file {path}: {error}",
|
|
55
|
+
config_file=str(path),
|
|
56
|
+
) from error
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_project_config(cwd: Path) -> Path | None:
|
|
60
|
+
agent_dir = cwd.resolve() / PROJECT_CONFIG_DIR_NAME
|
|
61
|
+
config_file = agent_dir / CONFIG_FILE_NAME
|
|
62
|
+
if config_file.is_file():
|
|
63
|
+
return config_file
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_agent_md_files(cwd: Path) -> str | None:
|
|
68
|
+
agent_md_file = cwd.resolve() / AGENT_MD_FILE
|
|
69
|
+
if agent_md_file.is_file():
|
|
70
|
+
return agent_md_file.read_text(encoding="utf-8")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
75
|
+
result = base.copy()
|
|
76
|
+
for key, value in override.items():
|
|
77
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
78
|
+
result[key] = _merge_dicts(result[key], value)
|
|
79
|
+
else:
|
|
80
|
+
result[key] = value
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _format_toml_value(value: Any) -> str:
|
|
85
|
+
if isinstance(value, bool):
|
|
86
|
+
return "true" if value else "false"
|
|
87
|
+
if isinstance(value, (int, float)):
|
|
88
|
+
return str(value)
|
|
89
|
+
if isinstance(value, str):
|
|
90
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
91
|
+
return f'"{escaped}"'
|
|
92
|
+
if isinstance(value, list):
|
|
93
|
+
return "[" + ", ".join(_format_toml_value(item) for item in value) + "]"
|
|
94
|
+
raise TypeError(f"Unsupported TOML value: {type(value)!r}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _dumps_toml(data: dict[str, Any]) -> str:
|
|
98
|
+
lines: list[str] = []
|
|
99
|
+
|
|
100
|
+
scalars = {
|
|
101
|
+
key: value
|
|
102
|
+
for key, value in data.items()
|
|
103
|
+
if not isinstance(value, dict) and value is not None
|
|
104
|
+
}
|
|
105
|
+
tables = {
|
|
106
|
+
key: value
|
|
107
|
+
for key, value in data.items()
|
|
108
|
+
if isinstance(value, dict) and value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for key, value in scalars.items():
|
|
112
|
+
lines.append(f"{key} = {_format_toml_value(value)}")
|
|
113
|
+
|
|
114
|
+
for table_name, table_values in tables.items():
|
|
115
|
+
if lines:
|
|
116
|
+
lines.append("")
|
|
117
|
+
lines.append(f"[{table_name}]")
|
|
118
|
+
for key, value in table_values.items():
|
|
119
|
+
if isinstance(value, dict):
|
|
120
|
+
raise TypeError("Nested TOML tables beyond one level are not supported")
|
|
121
|
+
if value is None:
|
|
122
|
+
continue
|
|
123
|
+
lines.append(f"{key} = {_format_toml_value(value)}")
|
|
124
|
+
|
|
125
|
+
return "\n".join(lines) + "\n"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def save_system_config(updates: dict[str, Any]) -> Path:
|
|
129
|
+
config_dir = ensure_config_dir()
|
|
130
|
+
config_path = config_dir / CONFIG_FILE_NAME
|
|
131
|
+
current: dict[str, Any] = {}
|
|
132
|
+
|
|
133
|
+
if config_path.is_file():
|
|
134
|
+
current = _parse_toml(config_path)
|
|
135
|
+
|
|
136
|
+
merged = _merge_dicts(current, updates)
|
|
137
|
+
config_path.write_text(_dumps_toml(merged), encoding="utf-8")
|
|
138
|
+
return config_path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_config(cwd: Path | None) -> Config:
|
|
142
|
+
cwd = cwd or Path.cwd()
|
|
143
|
+
load_dotenv(cwd / ".env")
|
|
144
|
+
|
|
145
|
+
system_path = get_system_config_path()
|
|
146
|
+
config_dict: dict[str, Any] = {}
|
|
147
|
+
|
|
148
|
+
if system_path.is_file():
|
|
149
|
+
try:
|
|
150
|
+
config_dict = _parse_toml(system_path)
|
|
151
|
+
except ConfigError:
|
|
152
|
+
logger.warning("Skipping invalid system config: %s", system_path)
|
|
153
|
+
|
|
154
|
+
project_path = _get_project_config(cwd)
|
|
155
|
+
if project_path:
|
|
156
|
+
try:
|
|
157
|
+
project_config_dict = _parse_toml(project_path)
|
|
158
|
+
config_dict = _merge_dicts(config_dict, project_config_dict)
|
|
159
|
+
except ConfigError:
|
|
160
|
+
logger.warning("Skipping invalid project config: %s", project_path)
|
|
161
|
+
|
|
162
|
+
if "cwd" not in config_dict:
|
|
163
|
+
config_dict["cwd"] = cwd
|
|
164
|
+
|
|
165
|
+
if "developer_instructions" not in config_dict:
|
|
166
|
+
agent_md_content = _get_agent_md_files(cwd)
|
|
167
|
+
if agent_md_content:
|
|
168
|
+
config_dict["developer_instructions"] = agent_md_content
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
return Config(**config_dict)
|
|
172
|
+
except Exception as error:
|
|
173
|
+
raise ConfigError(f"Invalid configuration: {error}") from error
|
context/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Conversation context management."""
|
context/compaction.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from client.llm_client import LLMClient
|
|
3
|
+
from client.response import StreamEventType, TokenUsage
|
|
4
|
+
from context.manager import ContextManager
|
|
5
|
+
from prompts.system import get_compression_prompt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChatCompactor:
|
|
9
|
+
def __init__(self, client: LLMClient):
|
|
10
|
+
self.client = client
|
|
11
|
+
self.last_error: str | None = None
|
|
12
|
+
|
|
13
|
+
def _format_history_for_compaction(self, messages: list[dict[str, Any]]) -> str:
|
|
14
|
+
output = ["Here is the conversation that needs to be continue: \n"]
|
|
15
|
+
|
|
16
|
+
for msg in messages:
|
|
17
|
+
role = msg.get("role", "")
|
|
18
|
+
content = msg.get("content", "")
|
|
19
|
+
|
|
20
|
+
if role == "system":
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
if role == "tool":
|
|
24
|
+
tool_id = msg.get("tool_call_id", "unknown")
|
|
25
|
+
|
|
26
|
+
truncated = content[:2000] if len(content) > 2000 else content
|
|
27
|
+
if len(content) > 2000:
|
|
28
|
+
truncated += "\n... [tool output truncated]"
|
|
29
|
+
|
|
30
|
+
output.append(f"[Tool Result ({tool_id})]:\n{truncated}")
|
|
31
|
+
elif role == "assistant":
|
|
32
|
+
tool_details = []
|
|
33
|
+
if content:
|
|
34
|
+
truncated = content[:3000] if len(content) > 3000 else content
|
|
35
|
+
if len(content) > 3000:
|
|
36
|
+
truncated += "\n... [response truncated]"
|
|
37
|
+
output.append(f"Assistant:\n{truncated}")
|
|
38
|
+
|
|
39
|
+
if msg.get("tool_calls"):
|
|
40
|
+
for tc in msg["tool_calls"]:
|
|
41
|
+
func = tc.get("function", {})
|
|
42
|
+
name = func.get("name", "unknown")
|
|
43
|
+
args = func.get("arguments", "{}")
|
|
44
|
+
|
|
45
|
+
if len(args) > 500:
|
|
46
|
+
args = args[:500]
|
|
47
|
+
tool_details.append(f" - {name}({args})")
|
|
48
|
+
|
|
49
|
+
output.append("Assistant called tools:\n" + "\n".join(tool_details))
|
|
50
|
+
else:
|
|
51
|
+
truncated = content[:1500] if len(content) > 1500 else content
|
|
52
|
+
if len(content) > 1500:
|
|
53
|
+
truncated += "\n... [message truncated]"
|
|
54
|
+
output.append(f"User:\n{truncated}")
|
|
55
|
+
|
|
56
|
+
return "\n\n---\n\n".join(output)
|
|
57
|
+
|
|
58
|
+
async def compress(
|
|
59
|
+
self, context_manager: ContextManager
|
|
60
|
+
) -> tuple[str | None, TokenUsage | None]:
|
|
61
|
+
self.last_error = None
|
|
62
|
+
messages = context_manager.get_messages()
|
|
63
|
+
|
|
64
|
+
if len(messages) < 3:
|
|
65
|
+
return None, None
|
|
66
|
+
|
|
67
|
+
compression_messages = [
|
|
68
|
+
{
|
|
69
|
+
"role": "system",
|
|
70
|
+
"content": get_compression_prompt(),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"role": "user",
|
|
74
|
+
"content": self._format_history_for_compaction(messages),
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
summary = ""
|
|
80
|
+
usage = None
|
|
81
|
+
async for event in self.client.chat_completion(
|
|
82
|
+
compression_messages,
|
|
83
|
+
stream=False,
|
|
84
|
+
):
|
|
85
|
+
if event.type == StreamEventType.MESSAGE_COMPLETE:
|
|
86
|
+
usage = event.usage
|
|
87
|
+
summary += event.text_delta.content
|
|
88
|
+
|
|
89
|
+
if not summary or not usage:
|
|
90
|
+
self.last_error = "Compaction returned no summary."
|
|
91
|
+
return None, None
|
|
92
|
+
|
|
93
|
+
return summary, usage
|
|
94
|
+
except Exception as error:
|
|
95
|
+
self.last_error = str(error) or error.__class__.__name__
|
|
96
|
+
return None, None
|
context/loop_detector.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LoopDetector:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.max_exact_repeats = 10
|
|
8
|
+
self.max_cycle_length = 10
|
|
9
|
+
self._history: deque[str] = deque(maxlen=20)
|
|
10
|
+
|
|
11
|
+
def record_action(self, action_type: str, **details: Any):
|
|
12
|
+
output = [action_type]
|
|
13
|
+
|
|
14
|
+
if action_type == "tool_call":
|
|
15
|
+
output.append(details.get("tool_name", ""))
|
|
16
|
+
args = details.get("args", {})
|
|
17
|
+
|
|
18
|
+
if isinstance(args, dict):
|
|
19
|
+
for k in sorted(args.keys()):
|
|
20
|
+
output.append(f"{k}={str(args[k])}")
|
|
21
|
+
elif action_type == "response":
|
|
22
|
+
output.append(details.get("text", ""))
|
|
23
|
+
|
|
24
|
+
signature = "|".join(output)
|
|
25
|
+
self._history.append(signature)
|
|
26
|
+
|
|
27
|
+
def check_for_loop(self) -> str | None:
|
|
28
|
+
if len(self._history) < 2:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if len(self._history) >= self.max_exact_repeats:
|
|
32
|
+
recent = list(self._history)[-self.max_exact_repeats :]
|
|
33
|
+
if len(set(recent)) == 1:
|
|
34
|
+
return f"Same action repeated {self.max_exact_repeats} tiems"
|
|
35
|
+
|
|
36
|
+
if len(self._history) >= self.max_cycle_length * 2:
|
|
37
|
+
history = list(self._history)
|
|
38
|
+
|
|
39
|
+
for cycle_len in range(
|
|
40
|
+
2, min(self.max_cycle_length + 1, len(history) // 2 + 1)
|
|
41
|
+
):
|
|
42
|
+
recent = history[-cycle_len * 2 :]
|
|
43
|
+
if recent[:cycle_len] == recent[cycle_len:]:
|
|
44
|
+
return f"Detected repeating cycle of length {cycle_len}"
|
|
45
|
+
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def clear(self) -> None:
|
|
49
|
+
self._history.clear()
|