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.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. yycode-0.3.2.dist-info/top_level.txt +4 -0
@@ -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
+ }
@@ -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