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,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: tunacode.core.agents.delegation_tools
|
|
3
|
+
|
|
4
|
+
Delegation tools for multi-agent workflows using pydantic-ai's delegation pattern.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic_ai import RunContext
|
|
8
|
+
|
|
9
|
+
from tunacode.core.agents.research_agent import create_research_agent
|
|
10
|
+
from tunacode.core.state import StateManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_research_codebase_tool(state_manager: StateManager):
|
|
14
|
+
"""Factory to create research_codebase delegation tool with state_manager closure.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
state_manager: StateManager instance to access current model and session state
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Async function that delegates research queries to specialized research agent
|
|
21
|
+
|
|
22
|
+
Note:
|
|
23
|
+
Progress callback is retrieved from state_manager.session.tool_progress_callback
|
|
24
|
+
at runtime to support dynamic callback updates without agent recreation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
async def research_codebase(
|
|
28
|
+
ctx: RunContext[None],
|
|
29
|
+
query: str,
|
|
30
|
+
directories: list[str] | None = None,
|
|
31
|
+
max_files: int = 3,
|
|
32
|
+
) -> dict:
|
|
33
|
+
"""Delegate codebase research to specialized read-only agent.
|
|
34
|
+
|
|
35
|
+
This tool creates a child agent with read-only tools (grep, glob, list_dir, read_file)
|
|
36
|
+
and delegates research queries to it. The child agent's usage is automatically
|
|
37
|
+
aggregated with the parent agent's usage via ctx.usage.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
ctx: RunContext with usage tracking (automatically passed by pydantic-ai)
|
|
41
|
+
query: Research query describing what to find in the codebase
|
|
42
|
+
directories: List of directories to search (defaults to ["."])
|
|
43
|
+
max_files: Maximum number of files to analyze (hard limit: 3, enforced)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Structured research findings dict with:
|
|
47
|
+
- relevant_files: list of file paths discovered
|
|
48
|
+
- key_findings: list of important discoveries
|
|
49
|
+
- code_examples: relevant code snippets with explanations
|
|
50
|
+
- recommendations: next steps or areas needing attention
|
|
51
|
+
"""
|
|
52
|
+
# Enforce hard limit on max_files
|
|
53
|
+
max_files = min(max_files, 3)
|
|
54
|
+
|
|
55
|
+
if directories is None:
|
|
56
|
+
directories = ["."]
|
|
57
|
+
|
|
58
|
+
# Get current model from session (same model as parent agent)
|
|
59
|
+
model = state_manager.session.current_model
|
|
60
|
+
|
|
61
|
+
# Note: Research agent panel display is handled by node_processor.py
|
|
62
|
+
# which shows a purple panel with query details before execution
|
|
63
|
+
|
|
64
|
+
# Get progress callback from session (set at request time by UI)
|
|
65
|
+
progress_callback = state_manager.session.tool_progress_callback
|
|
66
|
+
|
|
67
|
+
research_agent = create_research_agent(
|
|
68
|
+
model, state_manager, max_files=max_files, progress_callback=progress_callback
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Construct research prompt
|
|
72
|
+
prompt = f"""Research the codebase for: {query}
|
|
73
|
+
|
|
74
|
+
Search in directories: {", ".join(directories)}
|
|
75
|
+
Analyze up to {max_files} most relevant files (hard limit: 3 files maximum).
|
|
76
|
+
|
|
77
|
+
Return a structured summary with:
|
|
78
|
+
- relevant_files: list of file paths found
|
|
79
|
+
- key_findings: list of important discoveries
|
|
80
|
+
- code_examples: relevant code snippets with explanations
|
|
81
|
+
- recommendations: next steps or areas needing attention
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
# Delegate to research agent with usage propagation
|
|
85
|
+
try:
|
|
86
|
+
result = await research_agent.run(
|
|
87
|
+
prompt,
|
|
88
|
+
usage=ctx.usage, # Share usage tracking with parent agent
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return result.output
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
# Catch all delegation errors (validation failures, pydantic-ai errors, etc.)
|
|
95
|
+
# Return structured error response instead of crashing
|
|
96
|
+
return {
|
|
97
|
+
"error": True,
|
|
98
|
+
"error_type": type(e).__name__,
|
|
99
|
+
"error_message": str(e),
|
|
100
|
+
"relevant_files": [],
|
|
101
|
+
"key_findings": [f"Error during research: {str(e)}"],
|
|
102
|
+
"code_examples": [],
|
|
103
|
+
"recommendations": [
|
|
104
|
+
"The research agent encountered an error. "
|
|
105
|
+
"Try simplifying the query or reducing the scope."
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return research_codebase
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""Module: tunacode.core.agents.main
|
|
2
|
+
|
|
3
|
+
Refactored main agent functionality with focused responsibility classes.
|
|
4
|
+
Handles agent creation, configuration, and request processing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from pydantic_ai import Agent
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pydantic_ai import Tool # noqa: F401
|
|
20
|
+
|
|
21
|
+
from tunacode.constants import UI_COLORS
|
|
22
|
+
from tunacode.core.compaction import prune_old_tool_outputs
|
|
23
|
+
from tunacode.core.state import StateManager
|
|
24
|
+
from tunacode.exceptions import GlobalRequestTimeoutError, UserAbortError
|
|
25
|
+
from tunacode.tools.react import ReactTool
|
|
26
|
+
from tunacode.types import (
|
|
27
|
+
AgentRun,
|
|
28
|
+
ModelName,
|
|
29
|
+
ToolCallback,
|
|
30
|
+
)
|
|
31
|
+
from tunacode.utils.ui import DotDict
|
|
32
|
+
|
|
33
|
+
from . import agent_components as ac
|
|
34
|
+
from .prompts import format_clarification, format_iteration_limit, format_no_progress
|
|
35
|
+
|
|
36
|
+
colors = DotDict(UI_COLORS)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"process_request",
|
|
40
|
+
"get_agent_tool",
|
|
41
|
+
"check_query_satisfaction",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AgentConfig:
|
|
47
|
+
"""Configuration for agent behavior."""
|
|
48
|
+
|
|
49
|
+
max_iterations: int = 15
|
|
50
|
+
unproductive_limit: int = 3
|
|
51
|
+
forced_react_interval: int = 2
|
|
52
|
+
forced_react_limit: int = 5
|
|
53
|
+
debug_metrics: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class RequestContext:
|
|
58
|
+
"""Context for a single request."""
|
|
59
|
+
|
|
60
|
+
request_id: str
|
|
61
|
+
max_iterations: int
|
|
62
|
+
debug_metrics: bool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EmptyResponseHandler:
|
|
66
|
+
"""Handles tracking and intervention for empty responses."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, state_manager: StateManager) -> None:
|
|
69
|
+
self.state_manager = state_manager
|
|
70
|
+
|
|
71
|
+
def track(self, is_empty: bool) -> None:
|
|
72
|
+
"""Track empty response and increment counter if empty."""
|
|
73
|
+
if is_empty:
|
|
74
|
+
current = getattr(self.state_manager.session, "consecutive_empty_responses", 0)
|
|
75
|
+
self.state_manager.session.consecutive_empty_responses = current + 1
|
|
76
|
+
else:
|
|
77
|
+
self.state_manager.session.consecutive_empty_responses = 0
|
|
78
|
+
|
|
79
|
+
def should_intervene(self) -> bool:
|
|
80
|
+
"""Check if intervention is needed (>= 1 consecutive empty)."""
|
|
81
|
+
return getattr(self.state_manager.session, "consecutive_empty_responses", 0) >= 1
|
|
82
|
+
|
|
83
|
+
async def prompt_action(self, message: str, reason: str, iteration: int) -> None:
|
|
84
|
+
"""Delegate to agent_components.handle_empty_response."""
|
|
85
|
+
|
|
86
|
+
# Create a minimal state-like object for compatibility
|
|
87
|
+
class StateProxy:
|
|
88
|
+
def __init__(self, sm: StateManager) -> None:
|
|
89
|
+
self.sm = sm
|
|
90
|
+
self.show_thoughts = bool(getattr(sm.session, "show_thoughts", False))
|
|
91
|
+
|
|
92
|
+
state_proxy = StateProxy(self.state_manager)
|
|
93
|
+
await ac.handle_empty_response(message, reason, iteration, state_proxy)
|
|
94
|
+
# Clear after intervention
|
|
95
|
+
self.state_manager.session.consecutive_empty_responses = 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class IterationManager:
|
|
99
|
+
"""Manages iteration tracking, productivity monitoring, and limit handling."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, state_manager: StateManager, config: AgentConfig) -> None:
|
|
102
|
+
self.state_manager = state_manager
|
|
103
|
+
self.config = config
|
|
104
|
+
self.unproductive_iterations = 0
|
|
105
|
+
self.last_productive_iteration = 0
|
|
106
|
+
|
|
107
|
+
def track_productivity(self, had_tool_use: bool, iteration: int) -> None:
|
|
108
|
+
"""Track productivity based on tool usage."""
|
|
109
|
+
if had_tool_use:
|
|
110
|
+
self.unproductive_iterations = 0
|
|
111
|
+
self.last_productive_iteration = iteration
|
|
112
|
+
else:
|
|
113
|
+
self.unproductive_iterations += 1
|
|
114
|
+
|
|
115
|
+
def should_force_action(self, response_state: ac.ResponseState) -> bool:
|
|
116
|
+
"""Check if action should be forced due to unproductivity."""
|
|
117
|
+
return (
|
|
118
|
+
self.unproductive_iterations >= self.config.unproductive_limit
|
|
119
|
+
and not response_state.task_completed
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def handle_iteration_limit(
|
|
123
|
+
self, iteration: int, response_state: ac.ResponseState
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Handle reaching iteration limit."""
|
|
126
|
+
if iteration >= self.config.max_iterations and not response_state.task_completed:
|
|
127
|
+
_, tools_str = ac.create_progress_summary(
|
|
128
|
+
getattr(self.state_manager.session, "tool_calls", [])
|
|
129
|
+
)
|
|
130
|
+
limit_message = format_iteration_limit(self.config.max_iterations, iteration, tools_str)
|
|
131
|
+
ac.create_user_message(limit_message, self.state_manager)
|
|
132
|
+
|
|
133
|
+
response_state.awaiting_user_guidance = True
|
|
134
|
+
|
|
135
|
+
def update_counters(self, iteration: int) -> None:
|
|
136
|
+
"""Update session iteration counters."""
|
|
137
|
+
self.state_manager.session.current_iteration = iteration
|
|
138
|
+
self.state_manager.session.iteration_count = iteration
|
|
139
|
+
|
|
140
|
+
async def force_action_if_unproductive(
|
|
141
|
+
self, message: str, iteration: int, response_state: ac.ResponseState
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Force action if unproductive iterations exceeded."""
|
|
144
|
+
if not self.should_force_action(response_state):
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
no_progress_message = format_no_progress(
|
|
148
|
+
message,
|
|
149
|
+
self.unproductive_iterations,
|
|
150
|
+
self.last_productive_iteration,
|
|
151
|
+
iteration,
|
|
152
|
+
self.config.max_iterations,
|
|
153
|
+
)
|
|
154
|
+
ac.create_user_message(no_progress_message, self.state_manager)
|
|
155
|
+
|
|
156
|
+
# Reset after nudge
|
|
157
|
+
self.unproductive_iterations = 0
|
|
158
|
+
|
|
159
|
+
async def ask_for_clarification(self, iteration: int) -> None:
|
|
160
|
+
"""Ask user for clarification."""
|
|
161
|
+
_, tools_used_str = ac.create_progress_summary(
|
|
162
|
+
getattr(self.state_manager.session, "tool_calls", [])
|
|
163
|
+
)
|
|
164
|
+
original_query = getattr(self.state_manager.session, "original_query", "your request")
|
|
165
|
+
clarification_message = format_clarification(original_query, iteration, tools_used_str)
|
|
166
|
+
ac.create_user_message(clarification_message, self.state_manager)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ReactSnapshotManager:
|
|
170
|
+
"""Manages forced react snapshots and guidance injection."""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self, state_manager: StateManager, react_tool: ReactTool, config: AgentConfig
|
|
174
|
+
) -> None:
|
|
175
|
+
self.state_manager = state_manager
|
|
176
|
+
self.react_tool = react_tool
|
|
177
|
+
self.config = config
|
|
178
|
+
|
|
179
|
+
def should_snapshot(self, iteration: int) -> bool:
|
|
180
|
+
"""Check if snapshot should be taken."""
|
|
181
|
+
if iteration < self.config.forced_react_interval:
|
|
182
|
+
return False
|
|
183
|
+
if iteration % self.config.forced_react_interval != 0:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
forced_calls = getattr(self.state_manager.session, "react_forced_calls", 0)
|
|
187
|
+
return forced_calls < self.config.forced_react_limit
|
|
188
|
+
|
|
189
|
+
async def capture_snapshot(
|
|
190
|
+
self, iteration: int, agent_run_ctx: Any, _show_debug: bool = False
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Capture react snapshot and inject guidance."""
|
|
193
|
+
if not self.should_snapshot(iteration):
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
await self.react_tool.execute(
|
|
198
|
+
action="think",
|
|
199
|
+
thoughts=f"Auto snapshot after iteration {iteration}",
|
|
200
|
+
next_action="continue",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Increment forced calls counter
|
|
204
|
+
forced_calls = getattr(self.state_manager.session, "react_forced_calls", 0)
|
|
205
|
+
self.state_manager.session.react_forced_calls = forced_calls + 1
|
|
206
|
+
|
|
207
|
+
# Build guidance from last tool call
|
|
208
|
+
timeline = self.state_manager.session.react_scratchpad.get("timeline", [])
|
|
209
|
+
latest = timeline[-1] if timeline else {"thoughts": "?", "next_action": "?"}
|
|
210
|
+
summary = latest.get("thoughts", "")
|
|
211
|
+
|
|
212
|
+
tool_calls = getattr(self.state_manager.session, "tool_calls", [])
|
|
213
|
+
if tool_calls:
|
|
214
|
+
last_tool = tool_calls[-1]
|
|
215
|
+
tool_name = last_tool.get("tool", "tool")
|
|
216
|
+
args = last_tool.get("args", {})
|
|
217
|
+
if isinstance(args, str):
|
|
218
|
+
try:
|
|
219
|
+
args = json.loads(args)
|
|
220
|
+
except (ValueError, TypeError):
|
|
221
|
+
args = {}
|
|
222
|
+
|
|
223
|
+
detail = ""
|
|
224
|
+
if tool_name == "grep" and isinstance(args, dict):
|
|
225
|
+
pattern = args.get("pattern")
|
|
226
|
+
detail = (
|
|
227
|
+
f"Review grep results for pattern '{pattern}'"
|
|
228
|
+
if pattern
|
|
229
|
+
else "Review grep results"
|
|
230
|
+
)
|
|
231
|
+
elif tool_name == "read_file" and isinstance(args, dict):
|
|
232
|
+
path = args.get("file_path") or args.get("filepath")
|
|
233
|
+
detail = (
|
|
234
|
+
f"Extract key notes from {path}" if path else "Summarize read_file output"
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
detail = f"Act on {tool_name} findings"
|
|
238
|
+
else:
|
|
239
|
+
detail = "Plan your first lookup"
|
|
240
|
+
|
|
241
|
+
guidance_entry = (
|
|
242
|
+
f"React snapshot {forced_calls + 1}/{self.config.forced_react_limit} "
|
|
243
|
+
f"at iteration {iteration}: {summary}. Next: {detail}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Append and trim guidance
|
|
247
|
+
self.state_manager.session.react_guidance.append(guidance_entry)
|
|
248
|
+
if len(self.state_manager.session.react_guidance) > self.config.forced_react_limit:
|
|
249
|
+
self.state_manager.session.react_guidance = (
|
|
250
|
+
self.state_manager.session.react_guidance[-self.config.forced_react_limit :]
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# CRITICAL: Inject into agent_run.ctx.messages so next LLM call sees guidance
|
|
254
|
+
if agent_run_ctx is not None:
|
|
255
|
+
ctx_messages = getattr(agent_run_ctx, "messages", None)
|
|
256
|
+
if isinstance(ctx_messages, list):
|
|
257
|
+
ModelRequest, _, SystemPromptPart = ac.get_model_messages()
|
|
258
|
+
system_part = SystemPromptPart(
|
|
259
|
+
content=f"[React Guidance] {guidance_entry}",
|
|
260
|
+
part_kind="system-prompt",
|
|
261
|
+
)
|
|
262
|
+
# CLAUDE_ANCHOR[react-system-injection]
|
|
263
|
+
# Append synthetic system message so LLM receives react guidance next turn
|
|
264
|
+
ctx_messages.append(ModelRequest(parts=[system_part], kind="request"))
|
|
265
|
+
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class RequestOrchestrator:
|
|
271
|
+
"""Orchestrates the main request processing loop."""
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
message: str,
|
|
276
|
+
model: ModelName,
|
|
277
|
+
state_manager: StateManager,
|
|
278
|
+
tool_callback: ToolCallback | None,
|
|
279
|
+
streaming_callback: Callable[[str], Awaitable[None]] | None,
|
|
280
|
+
tool_result_callback: Callable[..., None] | None = None,
|
|
281
|
+
tool_start_callback: Callable[[str], None] | None = None,
|
|
282
|
+
) -> None:
|
|
283
|
+
self.message = message
|
|
284
|
+
self.model = model
|
|
285
|
+
self.state_manager = state_manager
|
|
286
|
+
self.tool_callback = tool_callback
|
|
287
|
+
self.streaming_callback = streaming_callback
|
|
288
|
+
self.tool_result_callback = tool_result_callback
|
|
289
|
+
self.tool_start_callback = tool_start_callback
|
|
290
|
+
|
|
291
|
+
# Initialize config from session settings
|
|
292
|
+
user_config = getattr(state_manager.session, "user_config", {}) or {}
|
|
293
|
+
settings = user_config.get("settings", {})
|
|
294
|
+
self.config = AgentConfig(
|
|
295
|
+
max_iterations=int(settings.get("max_iterations", 15)),
|
|
296
|
+
unproductive_limit=3,
|
|
297
|
+
forced_react_interval=2,
|
|
298
|
+
forced_react_limit=5,
|
|
299
|
+
debug_metrics=bool(settings.get("debug_metrics", False)),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Initialize managers
|
|
303
|
+
self.empty_handler = EmptyResponseHandler(state_manager)
|
|
304
|
+
self.iteration_manager = IterationManager(state_manager, self.config)
|
|
305
|
+
self.react_manager = ReactSnapshotManager(
|
|
306
|
+
state_manager, ReactTool(state_manager=state_manager), self.config
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _init_request_context(self) -> RequestContext:
|
|
310
|
+
"""Initialize request context with ID and config."""
|
|
311
|
+
req_id = str(uuid.uuid4())[:8]
|
|
312
|
+
self.state_manager.session.request_id = req_id
|
|
313
|
+
|
|
314
|
+
return RequestContext(
|
|
315
|
+
request_id=req_id,
|
|
316
|
+
max_iterations=self.config.max_iterations,
|
|
317
|
+
debug_metrics=self.config.debug_metrics,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _reset_session_state(self) -> None:
|
|
321
|
+
"""Reset/initialize fields needed for a new run."""
|
|
322
|
+
self.state_manager.session.current_iteration = 0
|
|
323
|
+
self.state_manager.session.iteration_count = 0
|
|
324
|
+
self.state_manager.session.tool_calls = []
|
|
325
|
+
self.state_manager.session.tool_call_args_by_id = {}
|
|
326
|
+
self.state_manager.session.react_forced_calls = 0
|
|
327
|
+
self.state_manager.session.react_guidance = []
|
|
328
|
+
|
|
329
|
+
# Counter used by other subsystems; initialize if absent
|
|
330
|
+
if not hasattr(self.state_manager.session, "batch_counter"):
|
|
331
|
+
self.state_manager.session.batch_counter = 0
|
|
332
|
+
|
|
333
|
+
# Track empty response streaks
|
|
334
|
+
self.state_manager.session.consecutive_empty_responses = 0
|
|
335
|
+
|
|
336
|
+
self.state_manager.session.original_query = ""
|
|
337
|
+
|
|
338
|
+
def _set_original_query_once(self, query: str) -> None:
|
|
339
|
+
"""Set original query if not already set."""
|
|
340
|
+
if not getattr(self.state_manager.session, "original_query", None):
|
|
341
|
+
self.state_manager.session.original_query = query
|
|
342
|
+
|
|
343
|
+
async def run(self) -> AgentRun:
|
|
344
|
+
"""Run the main request processing loop with optional global timeout."""
|
|
345
|
+
from tunacode.core.agents.agent_components.agent_config import (
|
|
346
|
+
_coerce_global_request_timeout,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
timeout = _coerce_global_request_timeout(self.state_manager)
|
|
350
|
+
if timeout is None:
|
|
351
|
+
return await self._run_impl()
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
return await asyncio.wait_for(self._run_impl(), timeout=timeout)
|
|
355
|
+
except TimeoutError as e:
|
|
356
|
+
raise GlobalRequestTimeoutError(timeout) from e
|
|
357
|
+
|
|
358
|
+
async def _run_impl(self) -> AgentRun:
|
|
359
|
+
"""Internal implementation of request processing loop."""
|
|
360
|
+
ctx = self._init_request_context()
|
|
361
|
+
self._reset_session_state()
|
|
362
|
+
self._set_original_query_once(self.message)
|
|
363
|
+
|
|
364
|
+
# Acquire agent
|
|
365
|
+
agent = ac.get_or_create_agent(self.model, self.state_manager)
|
|
366
|
+
|
|
367
|
+
# Prune old tool outputs directly in session (persisted)
|
|
368
|
+
session_messages = self.state_manager.session.messages
|
|
369
|
+
_, tokens_reclaimed = prune_old_tool_outputs(session_messages, self.model)
|
|
370
|
+
|
|
371
|
+
# Prepare history snapshot (now pruned)
|
|
372
|
+
message_history = list(session_messages)
|
|
373
|
+
|
|
374
|
+
# Per-request trackers
|
|
375
|
+
tool_buffer = ac.ToolBuffer()
|
|
376
|
+
response_state = ac.ResponseState()
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
async with agent.iter(self.message, message_history=message_history) as agent_run:
|
|
380
|
+
i = 1
|
|
381
|
+
async for node in agent_run:
|
|
382
|
+
self.iteration_manager.update_counters(i)
|
|
383
|
+
|
|
384
|
+
# Optional token streaming
|
|
385
|
+
await _maybe_stream_node_tokens(
|
|
386
|
+
node,
|
|
387
|
+
agent_run.ctx,
|
|
388
|
+
self.state_manager,
|
|
389
|
+
self.streaming_callback,
|
|
390
|
+
ctx.request_id,
|
|
391
|
+
i,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Core node processing
|
|
395
|
+
empty_response, empty_reason = await ac._process_node( # noqa: SLF001
|
|
396
|
+
node,
|
|
397
|
+
self.tool_callback,
|
|
398
|
+
self.state_manager,
|
|
399
|
+
tool_buffer,
|
|
400
|
+
self.streaming_callback,
|
|
401
|
+
response_state,
|
|
402
|
+
self.tool_result_callback,
|
|
403
|
+
self.tool_start_callback,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Handle empty response
|
|
407
|
+
self.empty_handler.track(empty_response)
|
|
408
|
+
if empty_response and self.empty_handler.should_intervene():
|
|
409
|
+
await self.empty_handler.prompt_action(self.message, empty_reason, i)
|
|
410
|
+
|
|
411
|
+
# Track whether we produced visible user output this iteration
|
|
412
|
+
if getattr(getattr(node, "result", None), "output", None):
|
|
413
|
+
response_state.has_user_response = True
|
|
414
|
+
|
|
415
|
+
# Productivity tracking
|
|
416
|
+
had_tool_use = _iteration_had_tool_use(node)
|
|
417
|
+
self.iteration_manager.track_productivity(had_tool_use, i)
|
|
418
|
+
|
|
419
|
+
# Force action if unproductive
|
|
420
|
+
await self.iteration_manager.force_action_if_unproductive(
|
|
421
|
+
self.message, i, response_state
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Force react snapshot
|
|
425
|
+
show_thoughts = bool(
|
|
426
|
+
getattr(self.state_manager.session, "show_thoughts", False)
|
|
427
|
+
)
|
|
428
|
+
await self.react_manager.capture_snapshot(i, agent_run.ctx, show_thoughts)
|
|
429
|
+
|
|
430
|
+
# Ask for clarification if agent requested it
|
|
431
|
+
if response_state.awaiting_user_guidance:
|
|
432
|
+
await self.iteration_manager.ask_for_clarification(i)
|
|
433
|
+
|
|
434
|
+
# Early completion
|
|
435
|
+
if response_state.task_completed:
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
# Handle iteration limit
|
|
439
|
+
await self.iteration_manager.handle_iteration_limit(i, response_state)
|
|
440
|
+
|
|
441
|
+
i += 1
|
|
442
|
+
|
|
443
|
+
await _finalize_buffered_tasks(
|
|
444
|
+
tool_buffer,
|
|
445
|
+
self.tool_callback,
|
|
446
|
+
self.state_manager,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Return wrapper that carries response_state
|
|
450
|
+
return ac.AgentRunWithState(agent_run, response_state)
|
|
451
|
+
|
|
452
|
+
except UserAbortError:
|
|
453
|
+
raise
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# Utility functions
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
async def _maybe_stream_node_tokens(
|
|
460
|
+
node: Any,
|
|
461
|
+
agent_run_ctx: Any,
|
|
462
|
+
state_manager: StateManager,
|
|
463
|
+
streaming_cb: Callable[[str], Awaitable[None]] | None,
|
|
464
|
+
request_id: str,
|
|
465
|
+
iteration_index: int,
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Stream tokens from model request nodes if callback provided.
|
|
468
|
+
|
|
469
|
+
Reference: main.py lines 146-161
|
|
470
|
+
"""
|
|
471
|
+
if not streaming_cb:
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
# Delegate to component streaming helper
|
|
475
|
+
if Agent.is_model_request_node(node): # type: ignore[attr-defined]
|
|
476
|
+
await ac.stream_model_request_node(
|
|
477
|
+
node, agent_run_ctx, state_manager, streaming_cb, request_id, iteration_index
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _iteration_had_tool_use(node: Any) -> bool:
|
|
482
|
+
"""Inspect the node to see if model responded with any tool-call parts.
|
|
483
|
+
|
|
484
|
+
Reference: main.py lines 164-171
|
|
485
|
+
"""
|
|
486
|
+
if hasattr(node, "model_response"):
|
|
487
|
+
for part in getattr(node.model_response, "parts", []):
|
|
488
|
+
# pydantic-ai annotates tool calls; be resilient to attr differences
|
|
489
|
+
if getattr(part, "part_kind", None) == "tool-call":
|
|
490
|
+
return True
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def _finalize_buffered_tasks(
|
|
495
|
+
tool_buffer: ac.ToolBuffer,
|
|
496
|
+
tool_callback: ToolCallback | None,
|
|
497
|
+
state_manager: StateManager,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Finalize and execute any buffered read-only tasks."""
|
|
500
|
+
if not tool_callback or not tool_buffer.has_tasks():
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
buffered_tasks = tool_buffer.flush()
|
|
504
|
+
|
|
505
|
+
# Execute
|
|
506
|
+
await ac.execute_tools_parallel(buffered_tasks, tool_callback)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def get_agent_tool() -> tuple[type[Agent], type[Tool]]:
|
|
510
|
+
"""Return Agent and Tool classes without importing at module load time."""
|
|
511
|
+
from pydantic_ai import Agent as AgentCls
|
|
512
|
+
from pydantic_ai import Tool as ToolCls
|
|
513
|
+
|
|
514
|
+
return AgentCls, ToolCls
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
async def check_query_satisfaction(
|
|
518
|
+
agent: Agent,
|
|
519
|
+
original_query: str,
|
|
520
|
+
response: str,
|
|
521
|
+
state_manager: StateManager,
|
|
522
|
+
) -> bool:
|
|
523
|
+
"""Legacy hook for compatibility; completion still signaled via DONE marker."""
|
|
524
|
+
return True
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def process_request(
|
|
528
|
+
message: str,
|
|
529
|
+
model: ModelName,
|
|
530
|
+
state_manager: StateManager,
|
|
531
|
+
tool_callback: ToolCallback | None = None,
|
|
532
|
+
streaming_callback: Callable[[str], Awaitable[None]] | None = None,
|
|
533
|
+
tool_result_callback: Callable[..., None] | None = None,
|
|
534
|
+
tool_start_callback: Callable[[str], None] | None = None,
|
|
535
|
+
) -> AgentRun:
|
|
536
|
+
orchestrator = RequestOrchestrator(
|
|
537
|
+
message,
|
|
538
|
+
model,
|
|
539
|
+
state_manager,
|
|
540
|
+
tool_callback,
|
|
541
|
+
streaming_callback,
|
|
542
|
+
tool_result_callback,
|
|
543
|
+
tool_start_callback,
|
|
544
|
+
)
|
|
545
|
+
return await orchestrator.run()
|