yycode 0.3.2__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 +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- yycode-0.3.2.dist-info/top_level.txt +4 -0
agent/providers/base.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Base LLM provider interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Optional, Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ToolCall:
|
|
10
|
+
"""Represents a tool call request."""
|
|
11
|
+
id: str
|
|
12
|
+
name: str
|
|
13
|
+
args: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ChatResponse:
|
|
18
|
+
"""Represents a chat response from LLM."""
|
|
19
|
+
content: str
|
|
20
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
21
|
+
content_blocks: Optional[list[dict[str, Any]]] = None
|
|
22
|
+
raw_response: Any = None
|
|
23
|
+
usage: Optional[dict[str, int]] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LLMProvider(ABC):
|
|
27
|
+
"""Abstract base class for LLM providers."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def chat(
|
|
31
|
+
self,
|
|
32
|
+
messages: list[dict],
|
|
33
|
+
tools: list[dict],
|
|
34
|
+
system_prompt: Optional[str] = None,
|
|
35
|
+
stream_callback: Optional[Callable[[str, str], None]] = None,
|
|
36
|
+
) -> ChatResponse:
|
|
37
|
+
"""Send a chat request with tool support."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
async def count_tokens(
|
|
41
|
+
self,
|
|
42
|
+
messages: list[dict],
|
|
43
|
+
system_prompt: Optional[str] = None,
|
|
44
|
+
tools: Optional[list[dict]] = None,
|
|
45
|
+
) -> Optional[int]:
|
|
46
|
+
"""Return input token count when supported by the provider."""
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def close(self) -> None:
|
|
51
|
+
"""Close the provider client."""
|
|
52
|
+
pass
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""OpenAI LLM provider implementation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional, Callable
|
|
5
|
+
|
|
6
|
+
from openai import AsyncOpenAI
|
|
7
|
+
import tiktoken
|
|
8
|
+
|
|
9
|
+
from .base import LLMProvider, ChatResponse, ToolCall
|
|
10
|
+
from .text_tool_calls import TextToolCallStreamFilter, parse_text_tool_calls
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenAIProvider(LLMProvider):
|
|
14
|
+
"""OpenAI API provider (compatible with OpenAI-like APIs)."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: str,
|
|
19
|
+
model: str,
|
|
20
|
+
base_url: Optional[str] = None,
|
|
21
|
+
):
|
|
22
|
+
self.model = model
|
|
23
|
+
self.client = AsyncOpenAI(
|
|
24
|
+
api_key=api_key,
|
|
25
|
+
base_url=base_url
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def _convert_messages(self, messages: list[dict]) -> list[dict]:
|
|
29
|
+
"""Convert Anthropic-style messages to OpenAI format."""
|
|
30
|
+
openai_messages = []
|
|
31
|
+
for msg in messages:
|
|
32
|
+
role = msg["role"]
|
|
33
|
+
content = msg["content"]
|
|
34
|
+
|
|
35
|
+
if role == "user":
|
|
36
|
+
if isinstance(content, list):
|
|
37
|
+
text_parts = []
|
|
38
|
+
for block in content:
|
|
39
|
+
if block.get("type") == "tool_result":
|
|
40
|
+
# OpenAI handles tool results differently
|
|
41
|
+
openai_messages.append({
|
|
42
|
+
"role": "tool",
|
|
43
|
+
"tool_call_id": block["tool_use_id"],
|
|
44
|
+
"content": block["content"],
|
|
45
|
+
})
|
|
46
|
+
elif block.get("type") == "text":
|
|
47
|
+
text_parts.append(block["text"])
|
|
48
|
+
if text_parts:
|
|
49
|
+
openai_messages.append({
|
|
50
|
+
"role": "user",
|
|
51
|
+
"content": "\n".join(text_parts),
|
|
52
|
+
})
|
|
53
|
+
else:
|
|
54
|
+
openai_messages.append({"role": "user", "content": content})
|
|
55
|
+
elif role == "assistant":
|
|
56
|
+
if isinstance(content, list):
|
|
57
|
+
text_parts = []
|
|
58
|
+
tool_calls = []
|
|
59
|
+
for block in content:
|
|
60
|
+
if block.get("type") == "text":
|
|
61
|
+
text_parts.append(block["text"])
|
|
62
|
+
elif block.get("type") == "tool_use":
|
|
63
|
+
tool_calls.append({
|
|
64
|
+
"id": block["id"],
|
|
65
|
+
"type": "function",
|
|
66
|
+
"function": {
|
|
67
|
+
"name": block["name"],
|
|
68
|
+
"arguments": json.dumps(block["input"]),
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
assistant_msg = {
|
|
72
|
+
"role": "assistant",
|
|
73
|
+
"content": "\n".join(text_parts) if text_parts else None,
|
|
74
|
+
}
|
|
75
|
+
if tool_calls:
|
|
76
|
+
assistant_msg["tool_calls"] = tool_calls
|
|
77
|
+
reasoning_content = msg.get("reasoning_content")
|
|
78
|
+
if reasoning_content:
|
|
79
|
+
assistant_msg["reasoning_content"] = reasoning_content
|
|
80
|
+
openai_messages.append(assistant_msg)
|
|
81
|
+
else:
|
|
82
|
+
assistant_msg = {"role": "assistant", "content": content}
|
|
83
|
+
reasoning_content = msg.get("reasoning_content")
|
|
84
|
+
if reasoning_content:
|
|
85
|
+
assistant_msg["reasoning_content"] = reasoning_content
|
|
86
|
+
openai_messages.append(assistant_msg)
|
|
87
|
+
|
|
88
|
+
return openai_messages
|
|
89
|
+
|
|
90
|
+
def _convert_tools(self, tools: list[dict]) -> list[dict]:
|
|
91
|
+
"""Convert Anthropic-style tools to OpenAI format."""
|
|
92
|
+
openai_tools = []
|
|
93
|
+
for tool in tools:
|
|
94
|
+
openai_tools.append({
|
|
95
|
+
"type": "function",
|
|
96
|
+
"function": {
|
|
97
|
+
"name": tool["name"],
|
|
98
|
+
"description": tool["description"],
|
|
99
|
+
"parameters": tool["input_schema"],
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
return openai_tools
|
|
103
|
+
|
|
104
|
+
async def chat(
|
|
105
|
+
self,
|
|
106
|
+
messages: list[dict],
|
|
107
|
+
tools: list[dict],
|
|
108
|
+
system_prompt: Optional[str] = None,
|
|
109
|
+
stream_callback: Optional[Callable[[str, str], None]] = None,
|
|
110
|
+
) -> ChatResponse:
|
|
111
|
+
"""Send chat request to OpenAI API."""
|
|
112
|
+
openai_messages = self._convert_messages(messages)
|
|
113
|
+
openai_tools = self._convert_tools(tools) if tools else None
|
|
114
|
+
|
|
115
|
+
if system_prompt:
|
|
116
|
+
openai_messages.insert(0, {"role": "system", "content": system_prompt})
|
|
117
|
+
|
|
118
|
+
current_text = ""
|
|
119
|
+
tool_calls_data = []
|
|
120
|
+
reasoning_content = None
|
|
121
|
+
usage = None
|
|
122
|
+
|
|
123
|
+
if stream_callback:
|
|
124
|
+
text_filter = TextToolCallStreamFilter()
|
|
125
|
+
stream = await self.client.chat.completions.create(
|
|
126
|
+
model=self.model,
|
|
127
|
+
messages=openai_messages,
|
|
128
|
+
tools=openai_tools,
|
|
129
|
+
stream=True,
|
|
130
|
+
stream_options={"include_usage": True},
|
|
131
|
+
max_tokens=4096,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
current_tool_call = None
|
|
135
|
+
|
|
136
|
+
async for chunk in stream:
|
|
137
|
+
if getattr(chunk, "usage", None):
|
|
138
|
+
usage = self._extract_usage(chunk.usage)
|
|
139
|
+
if not chunk.choices:
|
|
140
|
+
continue
|
|
141
|
+
delta = chunk.choices[0].delta
|
|
142
|
+
|
|
143
|
+
delta_reasoning = getattr(delta, "reasoning_content", None)
|
|
144
|
+
if delta_reasoning:
|
|
145
|
+
reasoning_content = (reasoning_content or "") + delta_reasoning
|
|
146
|
+
|
|
147
|
+
if delta.content:
|
|
148
|
+
current_text += delta.content
|
|
149
|
+
for safe_text in text_filter.feed(delta.content):
|
|
150
|
+
await stream_callback("text_delta", safe_text)
|
|
151
|
+
|
|
152
|
+
if delta.tool_calls:
|
|
153
|
+
for tc in delta.tool_calls:
|
|
154
|
+
if tc.index >= len(tool_calls_data):
|
|
155
|
+
tool_calls_data.append({
|
|
156
|
+
"id": tc.id,
|
|
157
|
+
"name": tc.function.name if tc.function else None,
|
|
158
|
+
"args": "",
|
|
159
|
+
})
|
|
160
|
+
current_tool_call = tool_calls_data[-1]
|
|
161
|
+
if tc.function and tc.function.arguments:
|
|
162
|
+
current_tool_call["args"] += tc.function.arguments
|
|
163
|
+
for safe_text in text_filter.flush():
|
|
164
|
+
await stream_callback("text_delta", safe_text)
|
|
165
|
+
else:
|
|
166
|
+
response = await self.client.chat.completions.create(
|
|
167
|
+
model=self.model,
|
|
168
|
+
messages=openai_messages,
|
|
169
|
+
tools=openai_tools,
|
|
170
|
+
stream=False,
|
|
171
|
+
max_tokens=4096,
|
|
172
|
+
)
|
|
173
|
+
usage = self._extract_usage(getattr(response, "usage", None))
|
|
174
|
+
choice = response.choices[0]
|
|
175
|
+
reasoning_content = getattr(choice.message, "reasoning_content", None)
|
|
176
|
+
if choice.message.content:
|
|
177
|
+
current_text = choice.message.content
|
|
178
|
+
if choice.message.tool_calls:
|
|
179
|
+
for tc in choice.message.tool_calls:
|
|
180
|
+
tool_calls_data.append({
|
|
181
|
+
"id": tc.id,
|
|
182
|
+
"name": tc.function.name,
|
|
183
|
+
"args": tc.function.arguments,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
tool_calls = []
|
|
187
|
+
for tc in tool_calls_data:
|
|
188
|
+
try:
|
|
189
|
+
args = json.loads(tc["args"]) if tc["args"] else {}
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
args = {}
|
|
192
|
+
tool_calls.append(ToolCall(id=tc["id"], name=tc["name"], args=args))
|
|
193
|
+
|
|
194
|
+
cleaned_text, text_tool_calls = parse_text_tool_calls(current_text)
|
|
195
|
+
if text_tool_calls:
|
|
196
|
+
current_text = cleaned_text
|
|
197
|
+
tool_calls.extend(text_tool_calls)
|
|
198
|
+
|
|
199
|
+
return ChatResponse(
|
|
200
|
+
content=current_text,
|
|
201
|
+
tool_calls=tool_calls,
|
|
202
|
+
content_blocks=_openai_content_blocks(current_text, reasoning_content),
|
|
203
|
+
raw_response=None,
|
|
204
|
+
usage=usage,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def close(self) -> None:
|
|
208
|
+
"""Close the client."""
|
|
209
|
+
await self.client.close()
|
|
210
|
+
|
|
211
|
+
async def count_tokens(
|
|
212
|
+
self,
|
|
213
|
+
messages: list[dict],
|
|
214
|
+
system_prompt: Optional[str] = None,
|
|
215
|
+
tools: Optional[list[dict]] = None,
|
|
216
|
+
) -> Optional[int]:
|
|
217
|
+
"""Count input tokens for OpenAI chat-style requests using tiktoken."""
|
|
218
|
+
openai_messages = self._convert_messages(messages)
|
|
219
|
+
if system_prompt:
|
|
220
|
+
openai_messages.insert(0, {"role": "system", "content": system_prompt})
|
|
221
|
+
openai_tools = self._convert_tools(tools) if tools else None
|
|
222
|
+
|
|
223
|
+
encoding = self._encoding_for_model()
|
|
224
|
+
if encoding is None:
|
|
225
|
+
return None
|
|
226
|
+
total = 0
|
|
227
|
+
for message in openai_messages:
|
|
228
|
+
total += 3
|
|
229
|
+
for key, value in message.items():
|
|
230
|
+
if value is None:
|
|
231
|
+
continue
|
|
232
|
+
total += len(encoding.encode(self._stringify_token_value(value)))
|
|
233
|
+
if key == "name":
|
|
234
|
+
total += 1
|
|
235
|
+
total += 3
|
|
236
|
+
if openai_tools:
|
|
237
|
+
# Tool schema accounting is model-dependent; compact JSON keeps this close.
|
|
238
|
+
total += len(encoding.encode(json.dumps(openai_tools, separators=(",", ":"))))
|
|
239
|
+
return total
|
|
240
|
+
|
|
241
|
+
def _encoding_for_model(self):
|
|
242
|
+
try:
|
|
243
|
+
return tiktoken.encoding_for_model(self.model)
|
|
244
|
+
except Exception:
|
|
245
|
+
try:
|
|
246
|
+
return tiktoken.get_encoding("o200k_base")
|
|
247
|
+
except Exception:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
def _stringify_token_value(self, value: Any) -> str:
|
|
251
|
+
if isinstance(value, str):
|
|
252
|
+
return value
|
|
253
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
254
|
+
|
|
255
|
+
def _extract_usage(self, usage: Any) -> Optional[dict[str, int]]:
|
|
256
|
+
"""Normalize OpenAI usage data."""
|
|
257
|
+
if usage is None:
|
|
258
|
+
return None
|
|
259
|
+
input_tokens = getattr(usage, "prompt_tokens", None)
|
|
260
|
+
output_tokens = getattr(usage, "completion_tokens", None)
|
|
261
|
+
total_tokens = getattr(usage, "total_tokens", None)
|
|
262
|
+
if input_tokens is None and output_tokens is None and total_tokens is None:
|
|
263
|
+
return None
|
|
264
|
+
if total_tokens is None:
|
|
265
|
+
total_tokens = (input_tokens or 0) + (output_tokens or 0)
|
|
266
|
+
return {
|
|
267
|
+
"input_tokens": input_tokens or 0,
|
|
268
|
+
"output_tokens": output_tokens or 0,
|
|
269
|
+
"total_tokens": total_tokens or 0,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _openai_content_blocks(content: str, reasoning_content: Optional[str]) -> Optional[list[dict[str, Any]]]:
|
|
274
|
+
blocks: list[dict[str, Any]] = []
|
|
275
|
+
if reasoning_content:
|
|
276
|
+
blocks.append({"type": "reasoning_content", "reasoning_content": reasoning_content})
|
|
277
|
+
if content:
|
|
278
|
+
blocks.append({"type": "text", "text": content})
|
|
279
|
+
return blocks or None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Parse text-encoded tool calls emitted by some OpenAI-compatible models."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent.providers.base import ToolCall
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
FUNCTION_CALL_BEGIN = "<|FunctionCallBegin|>"
|
|
11
|
+
FUNCTION_CALL_END = "<|FunctionCallEnd|>"
|
|
12
|
+
|
|
13
|
+
_TEXT_TOOL_CALL_RE = re.compile(
|
|
14
|
+
rf"{re.escape(FUNCTION_CALL_BEGIN)}(.*?){re.escape(FUNCTION_CALL_END)}",
|
|
15
|
+
re.DOTALL,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_text_tool_calls(content: str) -> tuple[str, list[ToolCall]]:
|
|
20
|
+
"""Extract text-encoded tool calls and return cleaned assistant text."""
|
|
21
|
+
tool_calls: list[ToolCall] = []
|
|
22
|
+
|
|
23
|
+
for index, match in enumerate(_TEXT_TOOL_CALL_RE.finditer(content)):
|
|
24
|
+
payload = match.group(1).strip()
|
|
25
|
+
for call_index, item in enumerate(_load_call_items(payload)):
|
|
26
|
+
name = item.get("name")
|
|
27
|
+
if not name:
|
|
28
|
+
continue
|
|
29
|
+
args = _extract_args(item)
|
|
30
|
+
tool_calls.append(
|
|
31
|
+
ToolCall(
|
|
32
|
+
id=f"text-tool-{index}-{call_index}",
|
|
33
|
+
name=str(name),
|
|
34
|
+
args=args,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
cleaned = _TEXT_TOOL_CALL_RE.sub("", content).strip()
|
|
39
|
+
return cleaned, tool_calls
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_call_items(payload: str) -> list[dict[str, Any]]:
|
|
43
|
+
try:
|
|
44
|
+
loaded = json.loads(payload)
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
return []
|
|
47
|
+
if isinstance(loaded, dict):
|
|
48
|
+
return [loaded]
|
|
49
|
+
if isinstance(loaded, list):
|
|
50
|
+
return [item for item in loaded if isinstance(item, dict)]
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_args(item: dict[str, Any]) -> dict[str, Any]:
|
|
55
|
+
args = (
|
|
56
|
+
item.get("parameters")
|
|
57
|
+
or item.get("arguments")
|
|
58
|
+
or item.get("args")
|
|
59
|
+
or item.get("input")
|
|
60
|
+
or {}
|
|
61
|
+
)
|
|
62
|
+
if isinstance(args, str):
|
|
63
|
+
try:
|
|
64
|
+
args = json.loads(args)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
return {}
|
|
67
|
+
return args if isinstance(args, dict) else {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TextToolCallStreamFilter:
|
|
71
|
+
"""Suppress text-encoded tool call blocks from streamed text deltas."""
|
|
72
|
+
|
|
73
|
+
def __init__(self):
|
|
74
|
+
self.buffer = ""
|
|
75
|
+
|
|
76
|
+
def feed(self, chunk: str) -> list[str]:
|
|
77
|
+
"""Return safe chunks that can be shown to the user."""
|
|
78
|
+
self.buffer += chunk
|
|
79
|
+
output: list[str] = []
|
|
80
|
+
|
|
81
|
+
while self.buffer:
|
|
82
|
+
begin = self.buffer.find(FUNCTION_CALL_BEGIN)
|
|
83
|
+
if begin >= 0:
|
|
84
|
+
if begin > 0:
|
|
85
|
+
output.append(self.buffer[:begin])
|
|
86
|
+
end = self.buffer.find(FUNCTION_CALL_END, begin)
|
|
87
|
+
if end < 0:
|
|
88
|
+
self.buffer = self.buffer[begin:]
|
|
89
|
+
break
|
|
90
|
+
self.buffer = self.buffer[end + len(FUNCTION_CALL_END):]
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
keep = self._partial_begin_suffix_len(self.buffer)
|
|
94
|
+
if keep:
|
|
95
|
+
output.append(self.buffer[:-keep])
|
|
96
|
+
self.buffer = self.buffer[-keep:]
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
output.append(self.buffer)
|
|
100
|
+
self.buffer = ""
|
|
101
|
+
|
|
102
|
+
return [text for text in output if text]
|
|
103
|
+
|
|
104
|
+
def flush(self) -> list[str]:
|
|
105
|
+
"""Flush remaining safe text at stream end."""
|
|
106
|
+
if self.buffer.startswith(FUNCTION_CALL_BEGIN):
|
|
107
|
+
self.buffer = ""
|
|
108
|
+
return []
|
|
109
|
+
output = [self.buffer] if self.buffer else []
|
|
110
|
+
self.buffer = ""
|
|
111
|
+
return output
|
|
112
|
+
|
|
113
|
+
def _partial_begin_suffix_len(self, text: str) -> int:
|
|
114
|
+
max_len = min(len(text), len(FUNCTION_CALL_BEGIN) - 1)
|
|
115
|
+
for length in range(max_len, 0, -1):
|
|
116
|
+
if FUNCTION_CALL_BEGIN.startswith(text[-length:]):
|
|
117
|
+
return length
|
|
118
|
+
return 0
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Runtime approval orchestration."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from agent.approval import (
|
|
6
|
+
ApprovalRequest,
|
|
7
|
+
ApprovalCallback,
|
|
8
|
+
ApprovalDenied,
|
|
9
|
+
approval_cache_key,
|
|
10
|
+
approval_request_for_tool,
|
|
11
|
+
)
|
|
12
|
+
from agent.runtime.context import WorkflowState
|
|
13
|
+
from agent.streaming import StreamEvent, StreamEventCallback
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApprovalService:
|
|
17
|
+
"""Approve high-risk tool calls and inject approved=true."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
approval_callback: ApprovalCallback | None,
|
|
22
|
+
workflow_state: WorkflowState,
|
|
23
|
+
stream_callback: StreamEventCallback | None = None,
|
|
24
|
+
session_id: str = "",
|
|
25
|
+
source: str = "main",
|
|
26
|
+
role: str | None = None,
|
|
27
|
+
parent_session_id: str | None = None,
|
|
28
|
+
workdir: Path | str | None = None,
|
|
29
|
+
):
|
|
30
|
+
self.approval_callback = approval_callback
|
|
31
|
+
self.workflow_state = workflow_state
|
|
32
|
+
self.stream_callback = stream_callback
|
|
33
|
+
self.session_id = session_id
|
|
34
|
+
self.source = source
|
|
35
|
+
self.role = role
|
|
36
|
+
self.parent_session_id = parent_session_id
|
|
37
|
+
self.workdir = workdir
|
|
38
|
+
|
|
39
|
+
async def approve(self, tool_name: str, args: dict | None) -> dict:
|
|
40
|
+
"""Return tool args after approval, injecting approved=true when needed."""
|
|
41
|
+
args = dict(args or {})
|
|
42
|
+
request = approval_request_for_tool(tool_name, args, workdir=self.workdir)
|
|
43
|
+
if request is None:
|
|
44
|
+
return args
|
|
45
|
+
|
|
46
|
+
cache_key = approval_cache_key(request)
|
|
47
|
+
if cache_key in self.workflow_state.approved_write_keys:
|
|
48
|
+
args["approved"] = True
|
|
49
|
+
await self._emit_approval_resolved(request, "cached_approved")
|
|
50
|
+
return args
|
|
51
|
+
|
|
52
|
+
await self._emit_approval_diff_preview(request)
|
|
53
|
+
if self.approval_callback is None:
|
|
54
|
+
await self._emit_approval_required(request)
|
|
55
|
+
await self._emit_approval_resolved(request, "denied")
|
|
56
|
+
raise ApprovalDenied(request)
|
|
57
|
+
await self._emit_approval_required(request)
|
|
58
|
+
approved = await self.approval_callback(request)
|
|
59
|
+
if not approved:
|
|
60
|
+
await self._emit_approval_resolved(request, "denied")
|
|
61
|
+
raise ApprovalDenied(request)
|
|
62
|
+
|
|
63
|
+
self.workflow_state.approved_write_keys.add(cache_key)
|
|
64
|
+
args["approved"] = True
|
|
65
|
+
await self._emit_approval_resolved(request, "approved")
|
|
66
|
+
return args
|
|
67
|
+
|
|
68
|
+
async def _emit_approval_required(self, request: ApprovalRequest) -> None:
|
|
69
|
+
if self.stream_callback is None:
|
|
70
|
+
return
|
|
71
|
+
await self.stream_callback(
|
|
72
|
+
StreamEvent(
|
|
73
|
+
source=self.source,
|
|
74
|
+
session_id=self.session_id,
|
|
75
|
+
role=self.role,
|
|
76
|
+
parent_session_id=self.parent_session_id,
|
|
77
|
+
event_type="approval_required",
|
|
78
|
+
content=request.format(include_diff=False),
|
|
79
|
+
title=_approval_title(request, "Approve"),
|
|
80
|
+
detail=_approval_detail(request),
|
|
81
|
+
phase="blocked",
|
|
82
|
+
status="waiting_for_user",
|
|
83
|
+
tool_name=request.tool_name,
|
|
84
|
+
file_paths=_approval_paths(request),
|
|
85
|
+
metadata=_approval_metadata(request),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def _emit_approval_diff_preview(self, request: ApprovalRequest) -> None:
|
|
90
|
+
if self.stream_callback is None or not request.diff_preview:
|
|
91
|
+
return
|
|
92
|
+
await self.stream_callback(
|
|
93
|
+
StreamEvent(
|
|
94
|
+
source=self.source,
|
|
95
|
+
session_id=self.session_id,
|
|
96
|
+
role=self.role,
|
|
97
|
+
parent_session_id=self.parent_session_id,
|
|
98
|
+
event_type="tool_result",
|
|
99
|
+
content=request.diff_preview,
|
|
100
|
+
title="Review diff before approval",
|
|
101
|
+
detail=_approval_detail(request),
|
|
102
|
+
phase="reviewing",
|
|
103
|
+
status="waiting_for_user",
|
|
104
|
+
tool_name=request.tool_name,
|
|
105
|
+
file_paths=_approval_paths(request),
|
|
106
|
+
metadata={
|
|
107
|
+
**_approval_metadata(request),
|
|
108
|
+
"approval_preview": True,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def _emit_approval_resolved(self, request: ApprovalRequest, status: str) -> None:
|
|
114
|
+
if self.stream_callback is None:
|
|
115
|
+
return
|
|
116
|
+
phase = "blocked" if status == "denied" else "implementing"
|
|
117
|
+
await self.stream_callback(
|
|
118
|
+
StreamEvent(
|
|
119
|
+
source=self.source,
|
|
120
|
+
session_id=self.session_id,
|
|
121
|
+
role=self.role,
|
|
122
|
+
parent_session_id=self.parent_session_id,
|
|
123
|
+
event_type="approval_resolved",
|
|
124
|
+
content=status,
|
|
125
|
+
title=_approval_title(request, _approval_status_label(status)),
|
|
126
|
+
detail=_approval_detail(request),
|
|
127
|
+
phase=phase,
|
|
128
|
+
status=status,
|
|
129
|
+
tool_name=request.tool_name,
|
|
130
|
+
file_paths=_approval_paths(request),
|
|
131
|
+
metadata=_approval_metadata(request),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _approval_title(request: ApprovalRequest, prefix: str) -> str:
|
|
137
|
+
if request.action == "edit_file":
|
|
138
|
+
return f"{prefix} file edit"
|
|
139
|
+
if request.action == "create_file":
|
|
140
|
+
return f"{prefix} file creation"
|
|
141
|
+
if request.action == "run_command":
|
|
142
|
+
return f"{prefix} command"
|
|
143
|
+
return f"{prefix} action"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _approval_status_label(status: str) -> str:
|
|
147
|
+
return {
|
|
148
|
+
"approved": "Approved",
|
|
149
|
+
"cached_approved": "Approved",
|
|
150
|
+
"denied": "Denied",
|
|
151
|
+
}.get(status, status)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _approval_detail(request: ApprovalRequest) -> str:
|
|
155
|
+
if request.path:
|
|
156
|
+
return request.path
|
|
157
|
+
if request.command:
|
|
158
|
+
return request.command
|
|
159
|
+
return request.reason
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _approval_paths(request: ApprovalRequest) -> list[str] | None:
|
|
163
|
+
if not request.path:
|
|
164
|
+
return None
|
|
165
|
+
return [path.strip() for path in request.path.split(",") if path.strip()]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _approval_metadata(request: ApprovalRequest) -> dict:
|
|
169
|
+
approval_id = "|".join(
|
|
170
|
+
[
|
|
171
|
+
request.action,
|
|
172
|
+
request.tool_name,
|
|
173
|
+
request.path or request.command,
|
|
174
|
+
]
|
|
175
|
+
)
|
|
176
|
+
return {
|
|
177
|
+
"approval_id": approval_id,
|
|
178
|
+
"action": request.action,
|
|
179
|
+
"reason": request.reason,
|
|
180
|
+
"risk": request.risk,
|
|
181
|
+
"path": request.path,
|
|
182
|
+
"command": request.command,
|
|
183
|
+
"diff_preview": request.diff_preview,
|
|
184
|
+
}
|
agent/runtime/context.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Runtime context objects for graph execution."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from agent.approval import ApprovalCallback
|
|
8
|
+
from agent.providers.base import LLMProvider
|
|
9
|
+
from agent.streaming import StreamEventCallback
|
|
10
|
+
from agent.tool_retry import async_run_tool_with_retry
|
|
11
|
+
from agent.todo_manager import TodoManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class WorkflowState:
|
|
16
|
+
"""Mutable workflow state scoped to a single graph run."""
|
|
17
|
+
|
|
18
|
+
workspace_state_checked: bool = False
|
|
19
|
+
git_diff_checked: bool = False
|
|
20
|
+
needs_verify: bool = False
|
|
21
|
+
approved_write_keys: set[tuple[str, str, str]] = field(default_factory=set)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AgentRuntimeContext:
|
|
26
|
+
"""Dependencies and runtime state needed by graph nodes."""
|
|
27
|
+
|
|
28
|
+
provider: LLMProvider
|
|
29
|
+
system_prompt: str
|
|
30
|
+
todo_manager: TodoManager
|
|
31
|
+
workdir: Path
|
|
32
|
+
session_id: str
|
|
33
|
+
source: str = "main"
|
|
34
|
+
role: str | None = None
|
|
35
|
+
parent_session_id: str | None = None
|
|
36
|
+
skill_dirs: list[str] | None = None
|
|
37
|
+
app_root: Path | None = None
|
|
38
|
+
stream_callback: Optional[StreamEventCallback] = None
|
|
39
|
+
approval_callback: Optional[ApprovalCallback] = None
|
|
40
|
+
tools: list[dict] = field(default_factory=list)
|
|
41
|
+
tool_handlers: dict[str, Callable] = field(default_factory=dict)
|
|
42
|
+
workflow_state: WorkflowState = field(default_factory=WorkflowState)
|
|
43
|
+
run_tool: Callable = async_run_tool_with_retry
|