minion-code 0.1.0__py3-none-any.whl → 0.1.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.
- examples/cli_entrypoint.py +60 -0
- examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
- examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
- examples/components/messages_component.py +199 -0
- examples/file_freshness_example.py +22 -22
- examples/file_watching_example.py +32 -26
- examples/interruptible_tui.py +921 -3
- examples/repl_tui.py +129 -0
- examples/skills/example_usage.py +57 -0
- examples/start.py +173 -0
- minion_code/__init__.py +1 -1
- minion_code/acp_server/__init__.py +34 -0
- minion_code/acp_server/agent.py +539 -0
- minion_code/acp_server/hooks.py +354 -0
- minion_code/acp_server/main.py +194 -0
- minion_code/acp_server/permissions.py +142 -0
- minion_code/acp_server/test_client.py +104 -0
- minion_code/adapters/__init__.py +22 -0
- minion_code/adapters/output_adapter.py +207 -0
- minion_code/adapters/rich_adapter.py +169 -0
- minion_code/adapters/textual_adapter.py +254 -0
- minion_code/agents/__init__.py +2 -2
- minion_code/agents/code_agent.py +517 -104
- minion_code/agents/hooks.py +378 -0
- minion_code/cli.py +538 -429
- minion_code/cli_simple.py +665 -0
- minion_code/commands/__init__.py +136 -29
- minion_code/commands/clear_command.py +19 -46
- minion_code/commands/help_command.py +33 -49
- minion_code/commands/history_command.py +37 -55
- minion_code/commands/model_command.py +194 -0
- minion_code/commands/quit_command.py +9 -12
- minion_code/commands/resume_command.py +181 -0
- minion_code/commands/skill_command.py +89 -0
- minion_code/commands/status_command.py +48 -73
- minion_code/commands/tools_command.py +54 -52
- minion_code/commands/version_command.py +34 -69
- minion_code/components/ConfirmDialog.py +430 -0
- minion_code/components/Message.py +318 -97
- minion_code/components/MessageResponse.py +30 -29
- minion_code/components/Messages.py +351 -0
- minion_code/components/PromptInput.py +499 -245
- minion_code/components/__init__.py +24 -17
- minion_code/const.py +7 -0
- minion_code/screens/REPL.py +1453 -469
- minion_code/screens/__init__.py +1 -1
- minion_code/services/__init__.py +20 -20
- minion_code/services/event_system.py +19 -14
- minion_code/services/file_freshness_service.py +223 -170
- minion_code/skills/__init__.py +25 -0
- minion_code/skills/skill.py +128 -0
- minion_code/skills/skill_loader.py +198 -0
- minion_code/skills/skill_registry.py +177 -0
- minion_code/subagents/__init__.py +31 -0
- minion_code/subagents/builtin/__init__.py +30 -0
- minion_code/subagents/builtin/claude_code_guide.py +32 -0
- minion_code/subagents/builtin/explore.py +36 -0
- minion_code/subagents/builtin/general_purpose.py +19 -0
- minion_code/subagents/builtin/plan.py +61 -0
- minion_code/subagents/subagent.py +116 -0
- minion_code/subagents/subagent_loader.py +147 -0
- minion_code/subagents/subagent_registry.py +151 -0
- minion_code/tools/__init__.py +8 -2
- minion_code/tools/bash_tool.py +16 -3
- minion_code/tools/file_edit_tool.py +201 -104
- minion_code/tools/file_read_tool.py +183 -26
- minion_code/tools/file_write_tool.py +17 -3
- minion_code/tools/glob_tool.py +23 -2
- minion_code/tools/grep_tool.py +229 -21
- minion_code/tools/ls_tool.py +28 -3
- minion_code/tools/multi_edit_tool.py +89 -84
- minion_code/tools/python_interpreter_tool.py +9 -1
- minion_code/tools/skill_tool.py +210 -0
- minion_code/tools/task_tool.py +287 -0
- minion_code/tools/todo_read_tool.py +28 -24
- minion_code/tools/todo_write_tool.py +82 -65
- minion_code/{types.py → type_defs.py} +15 -2
- minion_code/utils/__init__.py +45 -17
- minion_code/utils/config.py +610 -0
- minion_code/utils/history.py +114 -0
- minion_code/utils/logs.py +53 -0
- minion_code/utils/mcp_loader.py +153 -55
- minion_code/utils/output_truncator.py +233 -0
- minion_code/utils/session_storage.py +369 -0
- minion_code/utils/todo_file_utils.py +26 -22
- minion_code/utils/todo_storage.py +43 -33
- minion_code/web/__init__.py +9 -0
- minion_code/web/adapters/__init__.py +5 -0
- minion_code/web/adapters/web_adapter.py +524 -0
- minion_code/web/api/__init__.py +7 -0
- minion_code/web/api/chat.py +277 -0
- minion_code/web/api/interactions.py +136 -0
- minion_code/web/api/sessions.py +135 -0
- minion_code/web/server.py +149 -0
- minion_code/web/services/__init__.py +5 -0
- minion_code/web/services/session_manager.py +420 -0
- minion_code-0.1.2.dist-info/METADATA +476 -0
- minion_code-0.1.2.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
- minion_code-0.1.2.dist-info/entry_points.txt +6 -0
- tests/test_adapter.py +67 -0
- tests/test_adapter_simple.py +79 -0
- tests/test_file_read_tool.py +144 -0
- tests/test_readonly_tools.py +0 -2
- tests/test_skills.py +441 -0
- examples/advance_tui.py +0 -508
- examples/rich_example.py +0 -4
- examples/simple_file_watching.py +0 -57
- examples/simple_tui.py +0 -267
- examples/simple_usage.py +0 -69
- minion_code-0.1.0.dist-info/METADATA +0 -350
- minion_code-0.1.0.dist-info/RECORD +0 -59
- minion_code-0.1.0.dist-info/entry_points.txt +0 -4
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Web Output Adapter - SSE implementation for cross-process communication.
|
|
5
|
+
|
|
6
|
+
This adapter outputs events to an asyncio.Queue for SSE streaming.
|
|
7
|
+
It follows the A2A-style input_required pattern for bidirectional interactions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional, Dict, Any, Literal
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field, asdict
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
from minion_code.adapters.output_adapter import OutputAdapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskState(str, Enum):
|
|
20
|
+
"""Task lifecycle states (A2A style)"""
|
|
21
|
+
|
|
22
|
+
SUBMITTED = "submitted"
|
|
23
|
+
WORKING = "working"
|
|
24
|
+
INPUT_REQUIRED = "input_required"
|
|
25
|
+
COMPLETED = "completed"
|
|
26
|
+
FAILED = "failed"
|
|
27
|
+
CANCELLED = "cancelled"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InputKind(str, Enum):
|
|
31
|
+
"""Types of user input requests"""
|
|
32
|
+
|
|
33
|
+
PERMISSION = "permission"
|
|
34
|
+
TEXT = "text"
|
|
35
|
+
CHOICE = "choice"
|
|
36
|
+
FORM = "form"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PermissionData:
|
|
41
|
+
"""Data for permission requests (tool use confirm)"""
|
|
42
|
+
|
|
43
|
+
resource_type: str # "tool", "file", "network", "system"
|
|
44
|
+
resource_name: str
|
|
45
|
+
resource_args: Optional[Dict[str, Any]] = None
|
|
46
|
+
risk_level: str = "medium" # "low", "medium", "high"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class TextInputData:
|
|
51
|
+
"""Data for text input requests"""
|
|
52
|
+
|
|
53
|
+
placeholder: str = ""
|
|
54
|
+
default_value: str = ""
|
|
55
|
+
multiline: bool = False
|
|
56
|
+
max_length: Optional[int] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ChoiceData:
|
|
61
|
+
"""Data for choice requests"""
|
|
62
|
+
|
|
63
|
+
choices: List[Dict[str, Any]] = field(default_factory=list)
|
|
64
|
+
allow_multiple: bool = False
|
|
65
|
+
default_index: int = 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class InputRequest:
|
|
70
|
+
"""Request for user input (A2A input_required)"""
|
|
71
|
+
|
|
72
|
+
interaction_id: str
|
|
73
|
+
kind: str # InputKind value
|
|
74
|
+
title: str
|
|
75
|
+
message: str
|
|
76
|
+
data: Dict[str, Any]
|
|
77
|
+
timeout_seconds: Optional[int] = 300
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class SSEEvent:
|
|
82
|
+
"""Server-Sent Event data structure"""
|
|
83
|
+
|
|
84
|
+
type: str
|
|
85
|
+
data: Dict[str, Any]
|
|
86
|
+
task_id: Optional[str] = None
|
|
87
|
+
timestamp: float = field(default_factory=time.time)
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
90
|
+
result = {"type": self.type, "timestamp": self.timestamp, **self.data}
|
|
91
|
+
if self.task_id:
|
|
92
|
+
result["task_id"] = self.task_id
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class PendingInteraction:
|
|
98
|
+
"""Represents a user interaction waiting for response"""
|
|
99
|
+
|
|
100
|
+
interaction_id: str
|
|
101
|
+
kind: str
|
|
102
|
+
data: Dict[str, Any]
|
|
103
|
+
future: asyncio.Future
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WebOutputAdapter(OutputAdapter):
|
|
107
|
+
"""
|
|
108
|
+
Web SSE adapter for cross-process frontend communication.
|
|
109
|
+
|
|
110
|
+
This adapter:
|
|
111
|
+
1. Outputs events to an asyncio.Queue for SSE streaming
|
|
112
|
+
2. Uses asyncio.Future for blocking interactions (confirm, choice, input)
|
|
113
|
+
3. Follows A2A-style input_required pattern
|
|
114
|
+
|
|
115
|
+
Workflow:
|
|
116
|
+
1. Agent calls adapter methods (panel, text, confirm, etc.)
|
|
117
|
+
2. Adapter creates SSEEvent and puts to event_queue
|
|
118
|
+
3. API endpoint reads from event_queue and sends SSE to client
|
|
119
|
+
4. For interactions, adapter creates Future and waits
|
|
120
|
+
5. Client responds via HTTP POST → resolve_interaction() → Future completes
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self, session_id: str, task_id: Optional[str] = None, timeout_seconds: int = 300
|
|
125
|
+
):
|
|
126
|
+
"""
|
|
127
|
+
Initialize Web adapter.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
session_id: Session identifier
|
|
131
|
+
task_id: Current task identifier (set per query)
|
|
132
|
+
timeout_seconds: Default timeout for interactions
|
|
133
|
+
"""
|
|
134
|
+
self.session_id = session_id
|
|
135
|
+
self.task_id = task_id
|
|
136
|
+
self.timeout_seconds = timeout_seconds
|
|
137
|
+
|
|
138
|
+
# Event queue for SSE output
|
|
139
|
+
self.event_queue: asyncio.Queue[SSEEvent] = asyncio.Queue()
|
|
140
|
+
|
|
141
|
+
# Pending interactions waiting for user response
|
|
142
|
+
self._pending_interactions: Dict[str, PendingInteraction] = {}
|
|
143
|
+
self._interaction_counter = 0
|
|
144
|
+
self._message_counter = 0
|
|
145
|
+
|
|
146
|
+
# Current task state
|
|
147
|
+
self._task_state = TaskState.SUBMITTED
|
|
148
|
+
|
|
149
|
+
def set_task_id(self, task_id: str):
|
|
150
|
+
"""Set current task ID for new query."""
|
|
151
|
+
self.task_id = task_id
|
|
152
|
+
self._task_state = TaskState.SUBMITTED
|
|
153
|
+
|
|
154
|
+
def _generate_interaction_id(self) -> str:
|
|
155
|
+
"""Generate unique interaction ID."""
|
|
156
|
+
self._interaction_counter += 1
|
|
157
|
+
return f"int_{self.session_id}_{self._interaction_counter}"
|
|
158
|
+
|
|
159
|
+
def _generate_message_id(self) -> str:
|
|
160
|
+
"""Generate unique message ID."""
|
|
161
|
+
self._message_counter += 1
|
|
162
|
+
return f"msg_{self.session_id}_{self._message_counter}"
|
|
163
|
+
|
|
164
|
+
async def _emit_event(self, event_type: str, data: Dict[str, Any]):
|
|
165
|
+
"""Emit SSE event to queue."""
|
|
166
|
+
event = SSEEvent(type=event_type, data=data, task_id=self.task_id)
|
|
167
|
+
await self.event_queue.put(event)
|
|
168
|
+
|
|
169
|
+
def _emit_event_sync(self, event_type: str, data: Dict[str, Any]):
|
|
170
|
+
"""Emit SSE event synchronously (for non-async methods)."""
|
|
171
|
+
event = SSEEvent(type=event_type, data=data, task_id=self.task_id)
|
|
172
|
+
# Use put_nowait for sync context
|
|
173
|
+
try:
|
|
174
|
+
self.event_queue.put_nowait(event)
|
|
175
|
+
except asyncio.QueueFull:
|
|
176
|
+
pass # Drop event if queue is full
|
|
177
|
+
|
|
178
|
+
async def emit_task_status(self, state: TaskState):
|
|
179
|
+
"""Emit task status change event."""
|
|
180
|
+
self._task_state = state
|
|
181
|
+
await self._emit_event(
|
|
182
|
+
"task_status", {"state": state.value, "session_id": self.session_id}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# ========== OutputAdapter interface implementation ==========
|
|
186
|
+
|
|
187
|
+
def panel(self, content: str, title: str = "", border_style: str = "blue") -> None:
|
|
188
|
+
"""Send panel output as SSE event."""
|
|
189
|
+
self._emit_event_sync(
|
|
190
|
+
"panel", {"content": content, "title": title, "style": border_style}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def table(self, headers: List[str], rows: List[List[str]], title: str = "") -> None:
|
|
194
|
+
"""Send table output as SSE event."""
|
|
195
|
+
self._emit_event_sync(
|
|
196
|
+
"table", {"headers": headers, "rows": rows, "title": title}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def text(self, content: str, style: str = "") -> None:
|
|
200
|
+
"""Send text output as SSE event."""
|
|
201
|
+
self._emit_event_sync("text", {"content": content, "style": style})
|
|
202
|
+
|
|
203
|
+
async def confirm(
|
|
204
|
+
self,
|
|
205
|
+
message: str,
|
|
206
|
+
title: str = "Confirm",
|
|
207
|
+
default: bool = False,
|
|
208
|
+
ok_text: str = "Yes",
|
|
209
|
+
cancel_text: str = "No",
|
|
210
|
+
) -> bool:
|
|
211
|
+
"""
|
|
212
|
+
Request user confirmation via input_required event.
|
|
213
|
+
|
|
214
|
+
This method:
|
|
215
|
+
1. Changes task state to input_required
|
|
216
|
+
2. Sends input_required SSE event
|
|
217
|
+
3. Creates Future and waits for response
|
|
218
|
+
4. Changes task state back to working
|
|
219
|
+
"""
|
|
220
|
+
interaction_id = self._generate_interaction_id()
|
|
221
|
+
future: asyncio.Future[bool] = asyncio.Future()
|
|
222
|
+
|
|
223
|
+
# Store pending interaction
|
|
224
|
+
self._pending_interactions[interaction_id] = PendingInteraction(
|
|
225
|
+
interaction_id=interaction_id,
|
|
226
|
+
kind=InputKind.PERMISSION.value,
|
|
227
|
+
data={
|
|
228
|
+
"message": message,
|
|
229
|
+
"title": title,
|
|
230
|
+
"default": default,
|
|
231
|
+
"ok_text": ok_text,
|
|
232
|
+
"cancel_text": cancel_text,
|
|
233
|
+
},
|
|
234
|
+
future=future,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Change state to input_required
|
|
238
|
+
await self.emit_task_status(TaskState.INPUT_REQUIRED)
|
|
239
|
+
|
|
240
|
+
# Send input_required event
|
|
241
|
+
await self._emit_event(
|
|
242
|
+
"input_required",
|
|
243
|
+
{
|
|
244
|
+
"request": {
|
|
245
|
+
"interaction_id": interaction_id,
|
|
246
|
+
"kind": InputKind.PERMISSION.value,
|
|
247
|
+
"title": title,
|
|
248
|
+
"message": message,
|
|
249
|
+
"data": {
|
|
250
|
+
"resource_type": "action",
|
|
251
|
+
"resource_name": title,
|
|
252
|
+
"default": default,
|
|
253
|
+
"ok_text": ok_text,
|
|
254
|
+
"cancel_text": cancel_text,
|
|
255
|
+
},
|
|
256
|
+
"timeout_seconds": self.timeout_seconds,
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Wait for user response
|
|
262
|
+
try:
|
|
263
|
+
result = await asyncio.wait_for(future, timeout=self.timeout_seconds)
|
|
264
|
+
return result
|
|
265
|
+
except asyncio.TimeoutError:
|
|
266
|
+
return default
|
|
267
|
+
finally:
|
|
268
|
+
self._pending_interactions.pop(interaction_id, None)
|
|
269
|
+
# Change state back to working
|
|
270
|
+
await self.emit_task_status(TaskState.WORKING)
|
|
271
|
+
|
|
272
|
+
async def choice(
|
|
273
|
+
self,
|
|
274
|
+
message: str,
|
|
275
|
+
choices: List[str],
|
|
276
|
+
title: str = "Select",
|
|
277
|
+
default_index: int = 0,
|
|
278
|
+
) -> int:
|
|
279
|
+
"""
|
|
280
|
+
Request user choice selection via input_required event.
|
|
281
|
+
|
|
282
|
+
Returns the selected index (0-based), or -1 if cancelled/timeout.
|
|
283
|
+
"""
|
|
284
|
+
interaction_id = self._generate_interaction_id()
|
|
285
|
+
future: asyncio.Future[int] = asyncio.Future()
|
|
286
|
+
|
|
287
|
+
self._pending_interactions[interaction_id] = PendingInteraction(
|
|
288
|
+
interaction_id=interaction_id,
|
|
289
|
+
kind=InputKind.CHOICE.value,
|
|
290
|
+
data={
|
|
291
|
+
"message": message,
|
|
292
|
+
"choices": choices,
|
|
293
|
+
"title": title,
|
|
294
|
+
"default_index": default_index,
|
|
295
|
+
},
|
|
296
|
+
future=future,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
await self.emit_task_status(TaskState.INPUT_REQUIRED)
|
|
300
|
+
|
|
301
|
+
await self._emit_event(
|
|
302
|
+
"input_required",
|
|
303
|
+
{
|
|
304
|
+
"request": {
|
|
305
|
+
"interaction_id": interaction_id,
|
|
306
|
+
"kind": InputKind.CHOICE.value,
|
|
307
|
+
"title": title,
|
|
308
|
+
"message": message,
|
|
309
|
+
"data": {
|
|
310
|
+
"choices": [
|
|
311
|
+
{"label": c, "value": i} for i, c in enumerate(choices)
|
|
312
|
+
],
|
|
313
|
+
"default_index": default_index,
|
|
314
|
+
},
|
|
315
|
+
"timeout_seconds": self.timeout_seconds,
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
result = await asyncio.wait_for(future, timeout=self.timeout_seconds)
|
|
322
|
+
return result
|
|
323
|
+
except asyncio.TimeoutError:
|
|
324
|
+
return -1
|
|
325
|
+
finally:
|
|
326
|
+
self._pending_interactions.pop(interaction_id, None)
|
|
327
|
+
await self.emit_task_status(TaskState.WORKING)
|
|
328
|
+
|
|
329
|
+
async def input(
|
|
330
|
+
self,
|
|
331
|
+
message: str,
|
|
332
|
+
title: str = "Input",
|
|
333
|
+
default: str = "",
|
|
334
|
+
placeholder: str = "",
|
|
335
|
+
) -> Optional[str]:
|
|
336
|
+
"""
|
|
337
|
+
Request user text input via input_required event.
|
|
338
|
+
|
|
339
|
+
Returns the input string, or None if cancelled/timeout.
|
|
340
|
+
"""
|
|
341
|
+
interaction_id = self._generate_interaction_id()
|
|
342
|
+
future: asyncio.Future[Optional[str]] = asyncio.Future()
|
|
343
|
+
|
|
344
|
+
self._pending_interactions[interaction_id] = PendingInteraction(
|
|
345
|
+
interaction_id=interaction_id,
|
|
346
|
+
kind=InputKind.TEXT.value,
|
|
347
|
+
data={
|
|
348
|
+
"message": message,
|
|
349
|
+
"title": title,
|
|
350
|
+
"default": default,
|
|
351
|
+
"placeholder": placeholder,
|
|
352
|
+
},
|
|
353
|
+
future=future,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
await self.emit_task_status(TaskState.INPUT_REQUIRED)
|
|
357
|
+
|
|
358
|
+
await self._emit_event(
|
|
359
|
+
"input_required",
|
|
360
|
+
{
|
|
361
|
+
"request": {
|
|
362
|
+
"interaction_id": interaction_id,
|
|
363
|
+
"kind": InputKind.TEXT.value,
|
|
364
|
+
"title": title,
|
|
365
|
+
"message": message,
|
|
366
|
+
"data": {"placeholder": placeholder, "default_value": default},
|
|
367
|
+
"timeout_seconds": self.timeout_seconds,
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
result = await asyncio.wait_for(future, timeout=self.timeout_seconds)
|
|
374
|
+
return result
|
|
375
|
+
except asyncio.TimeoutError:
|
|
376
|
+
return None
|
|
377
|
+
finally:
|
|
378
|
+
self._pending_interactions.pop(interaction_id, None)
|
|
379
|
+
await self.emit_task_status(TaskState.WORKING)
|
|
380
|
+
|
|
381
|
+
def print(self, *args, **kwargs) -> None:
|
|
382
|
+
"""Generic print - converts to text output."""
|
|
383
|
+
content = " ".join(str(arg) for arg in args)
|
|
384
|
+
self.text(content)
|
|
385
|
+
|
|
386
|
+
# ========== Interaction resolution ==========
|
|
387
|
+
|
|
388
|
+
def resolve_interaction(self, interaction_id: str, result: Any) -> bool:
|
|
389
|
+
"""
|
|
390
|
+
Resolve a pending interaction with user's response.
|
|
391
|
+
|
|
392
|
+
This method should be called by the HTTP endpoint when
|
|
393
|
+
user responds to an input_required event.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
interaction_id: The interaction ID from the request
|
|
397
|
+
result: The user's response (bool, int, or str depending on kind)
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
True if interaction was found and resolved, False otherwise
|
|
401
|
+
"""
|
|
402
|
+
interaction = self._pending_interactions.get(interaction_id)
|
|
403
|
+
if interaction and not interaction.future.done():
|
|
404
|
+
interaction.future.set_result(result)
|
|
405
|
+
return True
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
def cancel_interaction(self, interaction_id: str) -> bool:
|
|
409
|
+
"""
|
|
410
|
+
Cancel a pending interaction.
|
|
411
|
+
|
|
412
|
+
Sets appropriate default value based on interaction kind.
|
|
413
|
+
"""
|
|
414
|
+
interaction = self._pending_interactions.get(interaction_id)
|
|
415
|
+
if interaction and not interaction.future.done():
|
|
416
|
+
if interaction.kind == InputKind.PERMISSION.value:
|
|
417
|
+
interaction.future.set_result(False)
|
|
418
|
+
elif interaction.kind == InputKind.CHOICE.value:
|
|
419
|
+
interaction.future.set_result(-1)
|
|
420
|
+
elif interaction.kind == InputKind.TEXT.value:
|
|
421
|
+
interaction.future.set_result(None)
|
|
422
|
+
return True
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
def get_pending_interaction(
|
|
426
|
+
self, interaction_id: str
|
|
427
|
+
) -> Optional[PendingInteraction]:
|
|
428
|
+
"""Get a pending interaction by ID."""
|
|
429
|
+
return self._pending_interactions.get(interaction_id)
|
|
430
|
+
|
|
431
|
+
def has_pending_interactions(self) -> bool:
|
|
432
|
+
"""Check if there are any pending interactions."""
|
|
433
|
+
return len(self._pending_interactions) > 0
|
|
434
|
+
|
|
435
|
+
# ========== Streaming helpers ==========
|
|
436
|
+
|
|
437
|
+
async def emit_content(self, chunk: str):
|
|
438
|
+
"""Emit streaming content chunk."""
|
|
439
|
+
await self._emit_event("content", {"chunk": chunk})
|
|
440
|
+
|
|
441
|
+
async def emit_thinking(self, chunk: str):
|
|
442
|
+
"""Emit thinking/reasoning content chunk."""
|
|
443
|
+
await self._emit_event("thinking", {"chunk": chunk})
|
|
444
|
+
|
|
445
|
+
async def emit_tool_call(self, name: str, args: Dict[str, Any]):
|
|
446
|
+
"""Emit tool call event."""
|
|
447
|
+
await self._emit_event("tool_call", {"name": name, "args": args})
|
|
448
|
+
|
|
449
|
+
async def emit_tool_result(self, success: bool, output: str):
|
|
450
|
+
"""Emit tool result event."""
|
|
451
|
+
await self._emit_event("tool_result", {"success": success, "output": output})
|
|
452
|
+
|
|
453
|
+
async def emit_error(self, message: str, code: Optional[str] = None):
|
|
454
|
+
"""Emit error event."""
|
|
455
|
+
data = {"message": message}
|
|
456
|
+
if code:
|
|
457
|
+
data["code"] = code
|
|
458
|
+
await self._emit_event("error", data)
|
|
459
|
+
|
|
460
|
+
async def emit_done(self):
|
|
461
|
+
"""Emit stream end event."""
|
|
462
|
+
await self._emit_event("done", {})
|
|
463
|
+
|
|
464
|
+
# ========== Tool permission helper ==========
|
|
465
|
+
|
|
466
|
+
async def confirm_tool_use(
|
|
467
|
+
self, tool_name: str, tool_args: Dict[str, Any], description: str = ""
|
|
468
|
+
) -> bool:
|
|
469
|
+
"""
|
|
470
|
+
Request user permission for tool execution.
|
|
471
|
+
|
|
472
|
+
This is a convenience method for tool permission confirmation.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
tool_name: Name of the tool to execute
|
|
476
|
+
tool_args: Arguments to the tool
|
|
477
|
+
description: Human-readable description of what the tool will do
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
True if user allows, False if denied or timeout
|
|
481
|
+
"""
|
|
482
|
+
interaction_id = self._generate_interaction_id()
|
|
483
|
+
future: asyncio.Future[bool] = asyncio.Future()
|
|
484
|
+
|
|
485
|
+
self._pending_interactions[interaction_id] = PendingInteraction(
|
|
486
|
+
interaction_id=interaction_id,
|
|
487
|
+
kind=InputKind.PERMISSION.value,
|
|
488
|
+
data={
|
|
489
|
+
"tool_name": tool_name,
|
|
490
|
+
"tool_args": tool_args,
|
|
491
|
+
"description": description,
|
|
492
|
+
},
|
|
493
|
+
future=future,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
await self.emit_task_status(TaskState.INPUT_REQUIRED)
|
|
497
|
+
|
|
498
|
+
await self._emit_event(
|
|
499
|
+
"input_required",
|
|
500
|
+
{
|
|
501
|
+
"request": {
|
|
502
|
+
"interaction_id": interaction_id,
|
|
503
|
+
"kind": InputKind.PERMISSION.value,
|
|
504
|
+
"title": f"Allow {tool_name}?",
|
|
505
|
+
"message": description or f"Agent wants to execute {tool_name}",
|
|
506
|
+
"data": {
|
|
507
|
+
"resource_type": "tool",
|
|
508
|
+
"resource_name": tool_name,
|
|
509
|
+
"resource_args": tool_args,
|
|
510
|
+
"risk_level": "medium",
|
|
511
|
+
},
|
|
512
|
+
"timeout_seconds": self.timeout_seconds,
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
result = await asyncio.wait_for(future, timeout=self.timeout_seconds)
|
|
519
|
+
return result
|
|
520
|
+
except asyncio.TimeoutError:
|
|
521
|
+
return False
|
|
522
|
+
finally:
|
|
523
|
+
self._pending_interactions.pop(interaction_id, None)
|
|
524
|
+
await self.emit_task_status(TaskState.WORKING)
|