tunacode-cli 0.1.21__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Prompting engine for resolving {{placeholder}} syntax in prompts."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PromptingEngine:
|
|
11
|
+
"""Resolves {{placeholder}} syntax in prompt templates.
|
|
12
|
+
|
|
13
|
+
Built-in placeholders:
|
|
14
|
+
- {{CWD}}: Current working directory
|
|
15
|
+
- {{OS}}: Operating system name
|
|
16
|
+
- {{DATE}}: Current date in ISO format
|
|
17
|
+
|
|
18
|
+
Unknown placeholders are left unchanged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
PLACEHOLDER_PATTERN = re.compile(r"\{\{(.+?)\}\}")
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._providers: dict[str, Callable[[], str]] = {}
|
|
25
|
+
self._register_builtins()
|
|
26
|
+
|
|
27
|
+
def _register_builtins(self) -> None:
|
|
28
|
+
"""Register built-in placeholder providers."""
|
|
29
|
+
self._providers["CWD"] = os.getcwd
|
|
30
|
+
self._providers["OS"] = platform.system
|
|
31
|
+
self._providers["DATE"] = lambda: datetime.now().isoformat()
|
|
32
|
+
|
|
33
|
+
def register(self, name: str, provider: Callable[[], str]) -> None:
|
|
34
|
+
"""Register a custom placeholder provider.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
name: Placeholder name (without braces)
|
|
38
|
+
provider: Callable that returns the replacement string
|
|
39
|
+
"""
|
|
40
|
+
self._providers[name] = provider
|
|
41
|
+
|
|
42
|
+
def resolve(self, template: str) -> str:
|
|
43
|
+
"""Resolve all placeholders in the template.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
template: String containing {{placeholder}} syntax
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Template with resolved placeholders. Unknown placeholders
|
|
50
|
+
are left unchanged.
|
|
51
|
+
"""
|
|
52
|
+
if not template:
|
|
53
|
+
return template
|
|
54
|
+
|
|
55
|
+
def replace_match(match: re.Match[str]) -> str:
|
|
56
|
+
name = match.group(1).strip()
|
|
57
|
+
provider = self._providers.get(name)
|
|
58
|
+
if provider is None:
|
|
59
|
+
return match.group(0)
|
|
60
|
+
return provider()
|
|
61
|
+
|
|
62
|
+
return self.PLACEHOLDER_PATTERN.sub(replace_match, template)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Module-level singleton for convenience
|
|
66
|
+
_engine: PromptingEngine | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_prompting_engine() -> PromptingEngine:
|
|
70
|
+
"""Get the singleton prompting engine instance."""
|
|
71
|
+
global _engine
|
|
72
|
+
if _engine is None:
|
|
73
|
+
_engine = PromptingEngine()
|
|
74
|
+
return _engine
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_prompt(template: str) -> str:
|
|
78
|
+
"""Convenience function to resolve placeholders using the singleton engine."""
|
|
79
|
+
return get_prompting_engine().resolve(template)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compose_prompt(template: str, sections: dict[str, str]) -> str:
|
|
83
|
+
"""Compose a prompt by replacing section placeholders with content.
|
|
84
|
+
|
|
85
|
+
This handles the first layer of placeholder resolution (section composition).
|
|
86
|
+
Use resolve_prompt() afterward for dynamic values like {{CWD}}.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
template: Template string with {{SECTION_NAME}} placeholders
|
|
90
|
+
sections: Dict mapping section names to their content
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Template with section placeholders replaced
|
|
94
|
+
"""
|
|
95
|
+
result = template
|
|
96
|
+
for name, content in sections.items():
|
|
97
|
+
result = result.replace(f"{{{{{name}}}}}", content)
|
|
98
|
+
return result
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""System prompt section definitions."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SystemPromptSection(str, Enum):
|
|
7
|
+
"""Named sections for composing system prompts.
|
|
8
|
+
|
|
9
|
+
Each section corresponds to a file in the prompts/sections/ directory.
|
|
10
|
+
Section files can be .xml, .md, or .txt format.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Core agent identity and behavior
|
|
14
|
+
AGENT_ROLE = "AGENT_ROLE"
|
|
15
|
+
|
|
16
|
+
# Mandatory behavior rules and constraints
|
|
17
|
+
CRITICAL_RULES = "CRITICAL_RULES"
|
|
18
|
+
|
|
19
|
+
# Tool descriptions and access rules
|
|
20
|
+
TOOL_USE = "TOOL_USE"
|
|
21
|
+
|
|
22
|
+
# GLOB->GREP->READ search pattern guidance
|
|
23
|
+
SEARCH_PATTERN = "SEARCH_PATTERN"
|
|
24
|
+
|
|
25
|
+
# Task completion signaling (TUNACODE DONE:)
|
|
26
|
+
COMPLETION = "COMPLETION"
|
|
27
|
+
|
|
28
|
+
# Parallel execution rules for read-only tools
|
|
29
|
+
PARALLEL_EXEC = "PARALLEL_EXEC"
|
|
30
|
+
|
|
31
|
+
# Output formatting and style guidelines
|
|
32
|
+
OUTPUT_STYLE = "OUTPUT_STYLE"
|
|
33
|
+
|
|
34
|
+
# Few-shot examples and workflow demonstrations
|
|
35
|
+
EXAMPLES = "EXAMPLES"
|
|
36
|
+
|
|
37
|
+
# Advanced usage patterns
|
|
38
|
+
ADVANCED_PATTERNS = "ADVANCED_PATTERNS"
|
|
39
|
+
|
|
40
|
+
# Dynamic system info (CWD, OS, DATE placeholders)
|
|
41
|
+
SYSTEM_INFO = "SYSTEM_INFO"
|
|
42
|
+
|
|
43
|
+
# User-provided context and instructions
|
|
44
|
+
USER_INSTRUCTIONS = "USER_INSTRUCTIONS"
|
|
45
|
+
|
|
46
|
+
# Research-specific: structured output format
|
|
47
|
+
OUTPUT_FORMAT = "OUTPUT_FORMAT"
|
|
48
|
+
|
|
49
|
+
# Research-specific: file limits and constraints
|
|
50
|
+
CONSTRAINTS = "CONSTRAINTS"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Prompt templates for composing system prompts from sections."""
|
|
2
|
+
|
|
3
|
+
# Main agent template - default composition order
|
|
4
|
+
# SEARCH_PATTERN is placed early to ensure the agent sees the search funnel first
|
|
5
|
+
MAIN_TEMPLATE = """{{AGENT_ROLE}}
|
|
6
|
+
|
|
7
|
+
====
|
|
8
|
+
|
|
9
|
+
{{SEARCH_PATTERN}}
|
|
10
|
+
|
|
11
|
+
====
|
|
12
|
+
|
|
13
|
+
{{CRITICAL_RULES}}
|
|
14
|
+
|
|
15
|
+
====
|
|
16
|
+
|
|
17
|
+
{{TOOL_USE}}
|
|
18
|
+
|
|
19
|
+
====
|
|
20
|
+
|
|
21
|
+
{{COMPLETION}}
|
|
22
|
+
|
|
23
|
+
====
|
|
24
|
+
|
|
25
|
+
{{PARALLEL_EXEC}}
|
|
26
|
+
|
|
27
|
+
====
|
|
28
|
+
|
|
29
|
+
{{OUTPUT_STYLE}}
|
|
30
|
+
|
|
31
|
+
====
|
|
32
|
+
|
|
33
|
+
{{EXAMPLES}}
|
|
34
|
+
|
|
35
|
+
====
|
|
36
|
+
|
|
37
|
+
{{ADVANCED_PATTERNS}}
|
|
38
|
+
|
|
39
|
+
====
|
|
40
|
+
|
|
41
|
+
{{SYSTEM_INFO}}
|
|
42
|
+
|
|
43
|
+
====
|
|
44
|
+
|
|
45
|
+
{{USER_INSTRUCTIONS}}"""
|
|
46
|
+
|
|
47
|
+
# Research agent template - simpler structure focused on exploration
|
|
48
|
+
RESEARCH_TEMPLATE = """{{AGENT_ROLE}}
|
|
49
|
+
|
|
50
|
+
====
|
|
51
|
+
|
|
52
|
+
{{TOOL_USE}}
|
|
53
|
+
|
|
54
|
+
====
|
|
55
|
+
|
|
56
|
+
{{CONSTRAINTS}}
|
|
57
|
+
|
|
58
|
+
====
|
|
59
|
+
|
|
60
|
+
{{OUTPUT_FORMAT}}"""
|
|
61
|
+
|
|
62
|
+
# Model-specific template overrides
|
|
63
|
+
# Key: model name prefix (e.g., "gpt-5", "claude-opus")
|
|
64
|
+
# Value: custom template string
|
|
65
|
+
TEMPLATE_OVERRIDES: dict[str, str] = {
|
|
66
|
+
# Example:
|
|
67
|
+
# "gpt-5": GPT5_TEMPLATE,
|
|
68
|
+
# "claude-opus": OPUS_TEMPLATE,
|
|
69
|
+
}
|
tunacode/core/state.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Module: tunacode.core.state
|
|
2
|
+
|
|
3
|
+
State management system for session data in TunaCode CLI.
|
|
4
|
+
Handles user preferences, conversation history, and runtime state.
|
|
5
|
+
|
|
6
|
+
CLAUDE_ANCHOR[state-module]: Central state management and session tracking
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
16
|
+
|
|
17
|
+
from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
|
|
18
|
+
from tunacode.types import (
|
|
19
|
+
DeviceId,
|
|
20
|
+
InputSessions,
|
|
21
|
+
MessageHistory,
|
|
22
|
+
ModelName,
|
|
23
|
+
SessionId,
|
|
24
|
+
ToolArgs,
|
|
25
|
+
ToolCallId,
|
|
26
|
+
ToolName,
|
|
27
|
+
ToolProgressCallback,
|
|
28
|
+
UserConfig,
|
|
29
|
+
)
|
|
30
|
+
from tunacode.utils.messaging import estimate_tokens, get_message_content
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from tunacode.tools.authorization.handler import ToolHandler
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SessionState:
|
|
38
|
+
"""CLAUDE_ANCHOR[session-state]: Core session state container"""
|
|
39
|
+
|
|
40
|
+
user_config: UserConfig = field(default_factory=dict)
|
|
41
|
+
agents: dict[str, Any] = field(
|
|
42
|
+
default_factory=dict
|
|
43
|
+
) # Keep as dict[str, Any] for agent instances
|
|
44
|
+
agent_versions: dict[str, int] = field(default_factory=dict)
|
|
45
|
+
messages: MessageHistory = field(default_factory=list)
|
|
46
|
+
# Keep session default in sync with configuration default
|
|
47
|
+
current_model: ModelName = DEFAULT_USER_CONFIG["default_model"]
|
|
48
|
+
spinner: Any | None = None
|
|
49
|
+
tool_ignore: list[ToolName] = field(default_factory=list)
|
|
50
|
+
tool_progress_callback: ToolProgressCallback | None = None
|
|
51
|
+
yolo: bool = False
|
|
52
|
+
undo_initialized: bool = False
|
|
53
|
+
show_thoughts: bool = False
|
|
54
|
+
session_id: SessionId = field(default_factory=lambda: str(uuid.uuid4()))
|
|
55
|
+
device_id: DeviceId | None = None
|
|
56
|
+
input_sessions: InputSessions = field(default_factory=dict)
|
|
57
|
+
current_task: Any | None = None
|
|
58
|
+
# Persistence fields
|
|
59
|
+
project_id: str = ""
|
|
60
|
+
created_at: str = ""
|
|
61
|
+
last_modified: str = ""
|
|
62
|
+
working_directory: str = ""
|
|
63
|
+
# CLAUDE_ANCHOR[react-scratchpad]: Session scratchpad for ReAct tooling
|
|
64
|
+
react_scratchpad: dict[str, Any] = field(default_factory=lambda: {"timeline": []})
|
|
65
|
+
react_forced_calls: int = 0
|
|
66
|
+
react_guidance: list[str] = field(default_factory=list)
|
|
67
|
+
# CLAUDE_ANCHOR[todos]: Session todo list for task tracking
|
|
68
|
+
todos: list[dict[str, Any]] = field(default_factory=list)
|
|
69
|
+
# Operation state tracking
|
|
70
|
+
operation_cancelled: bool = False
|
|
71
|
+
# Enhanced tracking for thoughts display
|
|
72
|
+
files_in_context: set[str] = field(default_factory=set)
|
|
73
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
74
|
+
tool_call_args_by_id: dict[ToolCallId, ToolArgs] = field(default_factory=dict)
|
|
75
|
+
iteration_count: int = 0
|
|
76
|
+
current_iteration: int = 0
|
|
77
|
+
# Track streaming state to prevent spinner conflicts
|
|
78
|
+
is_streaming_active: bool = False
|
|
79
|
+
# Track streaming panel reference for tool handler access
|
|
80
|
+
streaming_panel: Any | None = None
|
|
81
|
+
# Context window tracking (estimation based)
|
|
82
|
+
total_tokens: int = 0
|
|
83
|
+
max_tokens: int = 0
|
|
84
|
+
# API usage tracking (actual from providers)
|
|
85
|
+
last_call_usage: dict = field(
|
|
86
|
+
default_factory=lambda: {
|
|
87
|
+
"prompt_tokens": 0,
|
|
88
|
+
"completion_tokens": 0,
|
|
89
|
+
"cost": 0.0,
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
session_total_usage: dict = field(
|
|
93
|
+
default_factory=lambda: {
|
|
94
|
+
"prompt_tokens": 0,
|
|
95
|
+
"completion_tokens": 0,
|
|
96
|
+
"cost": 0.0,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
# Recursive execution tracking
|
|
100
|
+
current_recursion_depth: int = 0
|
|
101
|
+
max_recursion_depth: int = 5
|
|
102
|
+
parent_task_id: str | None = None
|
|
103
|
+
task_hierarchy: dict[str, Any] = field(default_factory=dict)
|
|
104
|
+
iteration_budgets: dict[str, int] = field(default_factory=dict)
|
|
105
|
+
recursive_context_stack: list[dict[str, Any]] = field(default_factory=list)
|
|
106
|
+
# Streaming debug instrumentation (see core/agents/agent_components/streaming.py)
|
|
107
|
+
_debug_events: list[str] = field(default_factory=list)
|
|
108
|
+
_debug_raw_stream_accum: str = ""
|
|
109
|
+
# Request lifecycle metadata
|
|
110
|
+
request_id: str = ""
|
|
111
|
+
original_query: str = ""
|
|
112
|
+
# Agent execution counters
|
|
113
|
+
consecutive_empty_responses: int = 0
|
|
114
|
+
batch_counter: int = 0
|
|
115
|
+
|
|
116
|
+
def update_token_count(self) -> None:
|
|
117
|
+
"""Calculate total token count from conversation messages."""
|
|
118
|
+
total = 0
|
|
119
|
+
for msg in self.messages:
|
|
120
|
+
content = get_message_content(msg)
|
|
121
|
+
if content:
|
|
122
|
+
total += estimate_tokens(content, self.current_model)
|
|
123
|
+
self.total_tokens = total
|
|
124
|
+
|
|
125
|
+
def adjust_token_count(self, delta: int) -> None:
|
|
126
|
+
"""Adjust total_tokens by delta (negative for reclaimed tokens)."""
|
|
127
|
+
self.total_tokens = max(0, self.total_tokens + delta)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class StateManager:
|
|
131
|
+
"""CLAUDE_ANCHOR[state-manager]: Main state manager singleton"""
|
|
132
|
+
|
|
133
|
+
def __init__(self):
|
|
134
|
+
self._session = SessionState()
|
|
135
|
+
self._tool_handler: ToolHandler | None = None
|
|
136
|
+
self._load_user_configuration()
|
|
137
|
+
|
|
138
|
+
def _load_user_configuration(self) -> None:
|
|
139
|
+
"""Load user configuration from file and merge with defaults."""
|
|
140
|
+
from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
|
|
141
|
+
from tunacode.configuration.models import get_model_context_window
|
|
142
|
+
from tunacode.utils.config import load_config
|
|
143
|
+
|
|
144
|
+
# Load user config from file
|
|
145
|
+
user_config = load_config()
|
|
146
|
+
if user_config:
|
|
147
|
+
# Merge with defaults: user config takes precedence
|
|
148
|
+
merged_config = DEFAULT_USER_CONFIG.copy()
|
|
149
|
+
merged_config.update(user_config)
|
|
150
|
+
|
|
151
|
+
# Merge nested settings
|
|
152
|
+
if "settings" in user_config:
|
|
153
|
+
merged_config["settings"] = DEFAULT_USER_CONFIG["settings"].copy()
|
|
154
|
+
merged_config["settings"].update(user_config["settings"])
|
|
155
|
+
|
|
156
|
+
# Update session with merged configuration
|
|
157
|
+
self._session.user_config = merged_config
|
|
158
|
+
else:
|
|
159
|
+
# No user config file found, use defaults
|
|
160
|
+
self._session.user_config = DEFAULT_USER_CONFIG.copy()
|
|
161
|
+
|
|
162
|
+
# Update current_model to match the loaded user config
|
|
163
|
+
if self._session.user_config.get("default_model"):
|
|
164
|
+
self._session.current_model = self._session.user_config["default_model"]
|
|
165
|
+
|
|
166
|
+
# Initialize max_tokens from model's registry context window
|
|
167
|
+
self._session.max_tokens = get_model_context_window(self._session.current_model)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def session(self) -> SessionState:
|
|
171
|
+
return self._session
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def tool_handler(self) -> Optional["ToolHandler"]:
|
|
175
|
+
return self._tool_handler
|
|
176
|
+
|
|
177
|
+
def set_tool_handler(self, handler: "ToolHandler") -> None:
|
|
178
|
+
self._tool_handler = handler
|
|
179
|
+
|
|
180
|
+
def push_recursive_context(self, context: dict[str, Any]) -> None:
|
|
181
|
+
"""Push a new context onto the recursive execution stack."""
|
|
182
|
+
self._session.recursive_context_stack.append(context)
|
|
183
|
+
self._session.current_recursion_depth = (self._session.current_recursion_depth or 0) + 1
|
|
184
|
+
|
|
185
|
+
def pop_recursive_context(self) -> dict[str, Any] | None:
|
|
186
|
+
"""Pop the current context from the recursive execution stack."""
|
|
187
|
+
if self._session.recursive_context_stack:
|
|
188
|
+
self._session.current_recursion_depth = max(
|
|
189
|
+
0, self._session.current_recursion_depth - 1
|
|
190
|
+
)
|
|
191
|
+
return self._session.recursive_context_stack.pop()
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def set_task_iteration_budget(self, task_id: str, budget: int) -> None:
|
|
195
|
+
"""Set the iteration budget for a specific task."""
|
|
196
|
+
self._session.iteration_budgets[task_id] = budget
|
|
197
|
+
|
|
198
|
+
def get_task_iteration_budget(self, task_id: str) -> int:
|
|
199
|
+
"""Get the iteration budget for a specific task."""
|
|
200
|
+
return self._session.iteration_budgets.get(task_id, 10) # Default to 10
|
|
201
|
+
|
|
202
|
+
def can_recurse_deeper(self) -> bool:
|
|
203
|
+
"""Check if we can recurse deeper without exceeding limits."""
|
|
204
|
+
return self._session.current_recursion_depth < self._session.max_recursion_depth
|
|
205
|
+
|
|
206
|
+
def reset_recursive_state(self) -> None:
|
|
207
|
+
"""Reset all recursive execution state."""
|
|
208
|
+
self._session.current_recursion_depth = 0
|
|
209
|
+
self._session.parent_task_id = None
|
|
210
|
+
self._session.task_hierarchy.clear()
|
|
211
|
+
self._session.iteration_budgets.clear()
|
|
212
|
+
self._session.recursive_context_stack.clear()
|
|
213
|
+
|
|
214
|
+
# React scratchpad helpers
|
|
215
|
+
def get_react_scratchpad(self) -> dict[str, Any]:
|
|
216
|
+
return self._session.react_scratchpad
|
|
217
|
+
|
|
218
|
+
def append_react_entry(self, entry: dict[str, Any]) -> None:
|
|
219
|
+
timeline = self._session.react_scratchpad.setdefault("timeline", [])
|
|
220
|
+
timeline.append(entry)
|
|
221
|
+
|
|
222
|
+
def clear_react_scratchpad(self) -> None:
|
|
223
|
+
self._session.react_scratchpad = {"timeline": []}
|
|
224
|
+
|
|
225
|
+
# Todo list helpers
|
|
226
|
+
def get_todos(self) -> list[dict[str, Any]]:
|
|
227
|
+
"""Return the current todo list."""
|
|
228
|
+
return self._session.todos
|
|
229
|
+
|
|
230
|
+
def set_todos(self, todos: list[dict[str, Any]]) -> None:
|
|
231
|
+
"""Replace the entire todo list."""
|
|
232
|
+
self._session.todos = todos
|
|
233
|
+
|
|
234
|
+
def clear_todos(self) -> None:
|
|
235
|
+
"""Clear the todo list."""
|
|
236
|
+
self._session.todos = []
|
|
237
|
+
|
|
238
|
+
def reset_session(self) -> None:
|
|
239
|
+
"""Reset the session to a fresh state."""
|
|
240
|
+
self._session = SessionState()
|
|
241
|
+
|
|
242
|
+
# Session persistence methods
|
|
243
|
+
|
|
244
|
+
def _get_session_file_path(self) -> Path:
|
|
245
|
+
"""Get the file path for current session."""
|
|
246
|
+
from tunacode.utils.system.paths import get_session_storage_dir
|
|
247
|
+
|
|
248
|
+
storage_dir = get_session_storage_dir()
|
|
249
|
+
return storage_dir / f"{self._session.project_id}_{self._session.session_id}.json"
|
|
250
|
+
|
|
251
|
+
def _serialize_messages(self) -> list[dict]:
|
|
252
|
+
"""Serialize mixed message list to JSON-compatible dicts."""
|
|
253
|
+
try:
|
|
254
|
+
from pydantic import TypeAdapter
|
|
255
|
+
from pydantic_ai.messages import ModelMessage
|
|
256
|
+
|
|
257
|
+
msg_adapter = TypeAdapter(ModelMessage)
|
|
258
|
+
except ImportError:
|
|
259
|
+
msg_adapter = None
|
|
260
|
+
|
|
261
|
+
result = []
|
|
262
|
+
for msg in self._session.messages:
|
|
263
|
+
if isinstance(msg, dict):
|
|
264
|
+
result.append(msg)
|
|
265
|
+
elif msg_adapter is not None:
|
|
266
|
+
with suppress(TypeError, ValueError, AttributeError):
|
|
267
|
+
result.append(msg_adapter.dump_python(msg, mode="json"))
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
def _deserialize_messages(self, data: list[dict]) -> list:
|
|
271
|
+
"""Deserialize JSON dicts back to message objects."""
|
|
272
|
+
try:
|
|
273
|
+
from pydantic import TypeAdapter
|
|
274
|
+
from pydantic_ai.messages import ModelMessage
|
|
275
|
+
|
|
276
|
+
msg_adapter = TypeAdapter(ModelMessage)
|
|
277
|
+
except ImportError:
|
|
278
|
+
return data
|
|
279
|
+
|
|
280
|
+
result = []
|
|
281
|
+
for item in data:
|
|
282
|
+
if not isinstance(item, dict):
|
|
283
|
+
result.append(item)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if "thought" in item:
|
|
287
|
+
result.append(item)
|
|
288
|
+
elif item.get("kind") in ("request", "response"):
|
|
289
|
+
try:
|
|
290
|
+
result.append(msg_adapter.validate_python(item))
|
|
291
|
+
except Exception:
|
|
292
|
+
result.append(item)
|
|
293
|
+
else:
|
|
294
|
+
result.append(item)
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
def save_session(self) -> bool:
|
|
298
|
+
"""Save current session to disk."""
|
|
299
|
+
if not self._session.project_id:
|
|
300
|
+
pass
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
self._session.last_modified = datetime.now(UTC).isoformat()
|
|
304
|
+
|
|
305
|
+
session_data = {
|
|
306
|
+
"version": 1,
|
|
307
|
+
"session_id": self._session.session_id,
|
|
308
|
+
"project_id": self._session.project_id,
|
|
309
|
+
"created_at": self._session.created_at,
|
|
310
|
+
"last_modified": self._session.last_modified,
|
|
311
|
+
"working_directory": self._session.working_directory,
|
|
312
|
+
"current_model": self._session.current_model,
|
|
313
|
+
"total_tokens": self._session.total_tokens,
|
|
314
|
+
"session_total_usage": self._session.session_total_usage,
|
|
315
|
+
"tool_ignore": self._session.tool_ignore,
|
|
316
|
+
"yolo": self._session.yolo,
|
|
317
|
+
"react_scratchpad": self._session.react_scratchpad,
|
|
318
|
+
"todos": self._session.todos,
|
|
319
|
+
"messages": self._serialize_messages(),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
session_file = self._get_session_file_path()
|
|
324
|
+
session_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
325
|
+
with open(session_file, "w") as f:
|
|
326
|
+
json.dump(session_data, f, indent=2)
|
|
327
|
+
return True
|
|
328
|
+
except PermissionError:
|
|
329
|
+
return False
|
|
330
|
+
except OSError:
|
|
331
|
+
return False
|
|
332
|
+
except Exception:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
def load_session(self, session_id: str) -> bool:
|
|
336
|
+
"""Load a session from disk."""
|
|
337
|
+
from tunacode.configuration.models import get_model_context_window
|
|
338
|
+
from tunacode.utils.system.paths import get_session_storage_dir
|
|
339
|
+
|
|
340
|
+
storage_dir = get_session_storage_dir()
|
|
341
|
+
|
|
342
|
+
session_file = None
|
|
343
|
+
for file in storage_dir.glob(f"*_{session_id}.json"):
|
|
344
|
+
session_file = file
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
if not session_file or not session_file.exists():
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
with open(session_file) as f:
|
|
352
|
+
data = json.load(f)
|
|
353
|
+
|
|
354
|
+
self._session.session_id = data.get("session_id", session_id)
|
|
355
|
+
self._session.project_id = data.get("project_id", "")
|
|
356
|
+
self._session.created_at = data.get("created_at", "")
|
|
357
|
+
self._session.last_modified = data.get("last_modified", "")
|
|
358
|
+
self._session.working_directory = data.get("working_directory", "")
|
|
359
|
+
self._session.current_model = data.get(
|
|
360
|
+
"current_model", DEFAULT_USER_CONFIG["default_model"]
|
|
361
|
+
)
|
|
362
|
+
# Update max_tokens based on loaded model's context window
|
|
363
|
+
self._session.max_tokens = get_model_context_window(self._session.current_model)
|
|
364
|
+
self._session.total_tokens = data.get("total_tokens", 0)
|
|
365
|
+
self._session.session_total_usage = data.get(
|
|
366
|
+
"session_total_usage",
|
|
367
|
+
{"prompt_tokens": 0, "completion_tokens": 0, "cost": 0.0},
|
|
368
|
+
)
|
|
369
|
+
self._session.tool_ignore = data.get("tool_ignore", [])
|
|
370
|
+
self._session.yolo = data.get("yolo", False)
|
|
371
|
+
self._session.react_scratchpad = data.get("react_scratchpad", {"timeline": []})
|
|
372
|
+
self._session.todos = data.get("todos", [])
|
|
373
|
+
self._session.messages = self._deserialize_messages(data.get("messages", []))
|
|
374
|
+
|
|
375
|
+
return True
|
|
376
|
+
except json.JSONDecodeError:
|
|
377
|
+
return False
|
|
378
|
+
except Exception:
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
def list_sessions(self) -> list[dict]:
|
|
382
|
+
"""List available sessions for current project."""
|
|
383
|
+
from tunacode.utils.system.paths import get_session_storage_dir
|
|
384
|
+
|
|
385
|
+
storage_dir = get_session_storage_dir()
|
|
386
|
+
sessions: list[dict[str, Any]] = []
|
|
387
|
+
|
|
388
|
+
if not self._session.project_id:
|
|
389
|
+
return sessions
|
|
390
|
+
|
|
391
|
+
for file in storage_dir.glob(f"{self._session.project_id}_*.json"):
|
|
392
|
+
try:
|
|
393
|
+
with open(file) as f:
|
|
394
|
+
data = json.load(f)
|
|
395
|
+
sessions.append(
|
|
396
|
+
{
|
|
397
|
+
"session_id": data.get("session_id", ""),
|
|
398
|
+
"created_at": data.get("created_at", ""),
|
|
399
|
+
"last_modified": data.get("last_modified", ""),
|
|
400
|
+
"message_count": len(data.get("messages", [])),
|
|
401
|
+
"current_model": data.get("current_model", ""),
|
|
402
|
+
"file_path": str(file),
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
except Exception:
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
sessions.sort(key=lambda x: x.get("last_modified", ""), reverse=True)
|
|
409
|
+
return sessions
|