minion-code 0.1.0__py3-none-any.whl → 0.1.1__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.1.dist-info/METADATA +475 -0
- minion_code-0.1.1.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/WHEEL +1 -1
- minion_code-0.1.1.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.1.dist-info}/licenses/LICENSE +0 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Chat API endpoint with SSE streaming.
|
|
5
|
+
|
|
6
|
+
Handles chat messages and returns streaming responses via Server-Sent Events.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional, AsyncGenerator
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
15
|
+
from fastapi.responses import StreamingResponse
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from ..services.session_manager import session_manager, HistoryMode
|
|
19
|
+
from ..adapters.web_adapter import TaskState, SSEEvent
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChatRequest(BaseModel):
|
|
27
|
+
"""Request body for chat endpoint."""
|
|
28
|
+
|
|
29
|
+
message: str = Field(..., description="User message content")
|
|
30
|
+
history_mode: Optional[HistoryMode] = Field(
|
|
31
|
+
default=None, description="Override session's history mode for this request"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_sse_event(event: SSEEvent) -> str:
|
|
36
|
+
"""Format event as SSE string."""
|
|
37
|
+
data = json.dumps(event.to_dict(), ensure_ascii=False)
|
|
38
|
+
return f"data: {data}\n\n"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_sse_done() -> str:
|
|
42
|
+
"""Format done event."""
|
|
43
|
+
return "data: [DONE]\n\n"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def process_chat_stream(
|
|
47
|
+
session_id: str, message: str, history_mode: Optional[HistoryMode] = None
|
|
48
|
+
) -> AsyncGenerator[str, None]:
|
|
49
|
+
"""
|
|
50
|
+
Process chat message and yield SSE events.
|
|
51
|
+
|
|
52
|
+
This is the core streaming logic:
|
|
53
|
+
1. Get or create session
|
|
54
|
+
2. Create task ID and update adapter
|
|
55
|
+
3. Get agent (based on history mode)
|
|
56
|
+
4. Run agent and forward events
|
|
57
|
+
5. Save messages to storage
|
|
58
|
+
"""
|
|
59
|
+
# Get session
|
|
60
|
+
session = await session_manager.get_session(session_id)
|
|
61
|
+
if not session:
|
|
62
|
+
yield format_sse_event(
|
|
63
|
+
SSEEvent(
|
|
64
|
+
type="error",
|
|
65
|
+
data={"message": "Session not found", "code": "SESSION_NOT_FOUND"},
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
yield format_sse_done()
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Use request history_mode or session default
|
|
72
|
+
effective_history_mode = history_mode or session.history_mode
|
|
73
|
+
|
|
74
|
+
# Generate task ID
|
|
75
|
+
task_id = session.generate_task_id()
|
|
76
|
+
session.current_task_id = task_id
|
|
77
|
+
session.adapter.set_task_id(task_id)
|
|
78
|
+
|
|
79
|
+
# Reset abort event
|
|
80
|
+
session.abort_event.clear()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Emit task started
|
|
84
|
+
await session.adapter.emit_task_status(TaskState.SUBMITTED)
|
|
85
|
+
yield format_sse_event(
|
|
86
|
+
SSEEvent(
|
|
87
|
+
type="task_status",
|
|
88
|
+
data={"state": TaskState.SUBMITTED.value, "task_id": task_id},
|
|
89
|
+
task_id=task_id,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Get or create agent
|
|
94
|
+
agent = await session_manager.get_or_create_agent(session)
|
|
95
|
+
|
|
96
|
+
# Emit working status
|
|
97
|
+
await session.adapter.emit_task_status(TaskState.WORKING)
|
|
98
|
+
yield format_sse_event(
|
|
99
|
+
SSEEvent(
|
|
100
|
+
type="task_status",
|
|
101
|
+
data={"state": TaskState.WORKING.value, "task_id": task_id},
|
|
102
|
+
task_id=task_id,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Save user message
|
|
107
|
+
session_manager.save_message(session, "user", message)
|
|
108
|
+
|
|
109
|
+
# Run agent with streaming
|
|
110
|
+
full_response = ""
|
|
111
|
+
|
|
112
|
+
# Create concurrent tasks for agent execution and event forwarding
|
|
113
|
+
async def run_agent():
|
|
114
|
+
nonlocal full_response
|
|
115
|
+
async for chunk in agent.run_async(message, stream=True):
|
|
116
|
+
# Check for abort
|
|
117
|
+
if session.abort_event.is_set():
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
chunk_type = getattr(chunk, "chunk_type", "text")
|
|
121
|
+
chunk_content = getattr(chunk, "content", str(chunk))
|
|
122
|
+
chunk_metadata = getattr(chunk, "metadata", {})
|
|
123
|
+
|
|
124
|
+
if chunk_type == "step_start":
|
|
125
|
+
await session.adapter._emit_event(
|
|
126
|
+
"step_start", {"content": chunk_content}
|
|
127
|
+
)
|
|
128
|
+
elif chunk_type == "thinking":
|
|
129
|
+
await session.adapter.emit_thinking(chunk_content)
|
|
130
|
+
elif chunk_type == "code_start":
|
|
131
|
+
await session.adapter._emit_event(
|
|
132
|
+
"code_start",
|
|
133
|
+
{
|
|
134
|
+
"code": chunk_content,
|
|
135
|
+
"language": chunk_metadata.get("language", ""),
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
elif chunk_type == "code_result":
|
|
139
|
+
await session.adapter.emit_tool_result(
|
|
140
|
+
success=chunk_metadata.get("success", True),
|
|
141
|
+
output=chunk_content,
|
|
142
|
+
)
|
|
143
|
+
elif chunk_type == "tool_call":
|
|
144
|
+
await session.adapter.emit_tool_call(
|
|
145
|
+
name=chunk_metadata.get("tool_name", ""),
|
|
146
|
+
args=chunk_metadata.get("args", {}),
|
|
147
|
+
)
|
|
148
|
+
elif chunk_type in ("final_answer", "agent_response", "completion"):
|
|
149
|
+
final_content = (
|
|
150
|
+
getattr(chunk, "answer", chunk_content) or chunk_content
|
|
151
|
+
)
|
|
152
|
+
full_response = str(final_content)
|
|
153
|
+
await session.adapter.emit_content(full_response)
|
|
154
|
+
else:
|
|
155
|
+
# Default: treat as content
|
|
156
|
+
if chunk_content:
|
|
157
|
+
await session.adapter.emit_content(chunk_content)
|
|
158
|
+
|
|
159
|
+
# Start agent execution in background
|
|
160
|
+
agent_task = asyncio.create_task(run_agent())
|
|
161
|
+
|
|
162
|
+
# Forward events from adapter queue to SSE
|
|
163
|
+
try:
|
|
164
|
+
while True:
|
|
165
|
+
try:
|
|
166
|
+
# Try to get event with timeout
|
|
167
|
+
event = await asyncio.wait_for(
|
|
168
|
+
session.adapter.event_queue.get(), timeout=0.1
|
|
169
|
+
)
|
|
170
|
+
yield format_sse_event(event)
|
|
171
|
+
except asyncio.TimeoutError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
# Check if agent is done
|
|
175
|
+
if agent_task.done():
|
|
176
|
+
# Drain remaining events
|
|
177
|
+
while not session.adapter.event_queue.empty():
|
|
178
|
+
event = await session.adapter.event_queue.get()
|
|
179
|
+
yield format_sse_event(event)
|
|
180
|
+
|
|
181
|
+
# Check for exception
|
|
182
|
+
if agent_task.exception():
|
|
183
|
+
raise agent_task.exception()
|
|
184
|
+
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
# Check for abort
|
|
188
|
+
if session.abort_event.is_set():
|
|
189
|
+
agent_task.cancel()
|
|
190
|
+
yield format_sse_event(
|
|
191
|
+
SSEEvent(
|
|
192
|
+
type="task_status",
|
|
193
|
+
data={
|
|
194
|
+
"state": TaskState.CANCELLED.value,
|
|
195
|
+
"task_id": task_id,
|
|
196
|
+
},
|
|
197
|
+
task_id=task_id,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.error(f"Error in event forwarding: {e}")
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
# Save assistant response
|
|
207
|
+
if full_response:
|
|
208
|
+
session_manager.save_message(session, "assistant", full_response)
|
|
209
|
+
|
|
210
|
+
# Emit completed status
|
|
211
|
+
await session.adapter.emit_task_status(TaskState.COMPLETED)
|
|
212
|
+
yield format_sse_event(
|
|
213
|
+
SSEEvent(
|
|
214
|
+
type="task_status",
|
|
215
|
+
data={"state": TaskState.COMPLETED.value, "task_id": task_id},
|
|
216
|
+
task_id=task_id,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
except asyncio.CancelledError:
|
|
221
|
+
yield format_sse_event(
|
|
222
|
+
SSEEvent(
|
|
223
|
+
type="task_status",
|
|
224
|
+
data={"state": TaskState.CANCELLED.value, "task_id": task_id},
|
|
225
|
+
task_id=task_id,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.exception(f"Error processing chat: {e}")
|
|
230
|
+
await session.adapter.emit_error(str(e))
|
|
231
|
+
yield format_sse_event(
|
|
232
|
+
SSEEvent(type="error", data={"message": str(e)}, task_id=task_id)
|
|
233
|
+
)
|
|
234
|
+
await session.adapter.emit_task_status(TaskState.FAILED)
|
|
235
|
+
yield format_sse_event(
|
|
236
|
+
SSEEvent(
|
|
237
|
+
type="task_status",
|
|
238
|
+
data={"state": TaskState.FAILED.value, "task_id": task_id},
|
|
239
|
+
task_id=task_id,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
finally:
|
|
243
|
+
session.current_task_id = None
|
|
244
|
+
yield format_sse_done()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@router.post("/{session_id}")
|
|
248
|
+
async def chat(session_id: str, request: ChatRequest):
|
|
249
|
+
"""
|
|
250
|
+
Send a chat message and receive streaming response.
|
|
251
|
+
|
|
252
|
+
Returns a Server-Sent Events stream with the following event types:
|
|
253
|
+
- task_status: Task state changes (submitted, working, input_required, completed, failed)
|
|
254
|
+
- content: Streaming text content
|
|
255
|
+
- thinking: LLM reasoning content
|
|
256
|
+
- tool_call: Tool invocation
|
|
257
|
+
- tool_result: Tool execution result
|
|
258
|
+
- input_required: Request for user input (permission, text, choice)
|
|
259
|
+
- error: Error message
|
|
260
|
+
- done: Stream end marker ([DONE])
|
|
261
|
+
|
|
262
|
+
For input_required events, respond via POST /api/tasks/{task_id}/input
|
|
263
|
+
"""
|
|
264
|
+
# Validate session exists
|
|
265
|
+
session = await session_manager.get_session(session_id)
|
|
266
|
+
if not session:
|
|
267
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
268
|
+
|
|
269
|
+
return StreamingResponse(
|
|
270
|
+
process_chat_stream(session_id, request.message, request.history_mode),
|
|
271
|
+
media_type="text/event-stream",
|
|
272
|
+
headers={
|
|
273
|
+
"Cache-Control": "no-cache",
|
|
274
|
+
"Connection": "keep-alive",
|
|
275
|
+
"X-Accel-Buffering": "no", # Disable nginx buffering
|
|
276
|
+
},
|
|
277
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Interactions API endpoint.
|
|
5
|
+
|
|
6
|
+
Handles user responses to input_required events (permission, text input, choice).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from typing import Union, Optional, Any
|
|
12
|
+
|
|
13
|
+
from ..services.session_manager import session_manager
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api/tasks", tags=["interactions"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InteractionResponse(BaseModel):
|
|
19
|
+
"""Request body for responding to an interaction."""
|
|
20
|
+
|
|
21
|
+
interaction_id: str = Field(
|
|
22
|
+
..., description="Interaction ID from input_required event"
|
|
23
|
+
)
|
|
24
|
+
response: Any = Field(
|
|
25
|
+
...,
|
|
26
|
+
description="User's response: bool for permission, int for choice, str for text",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InteractionResult(BaseModel):
|
|
31
|
+
"""Response after processing interaction."""
|
|
32
|
+
|
|
33
|
+
status: str
|
|
34
|
+
interaction_id: str
|
|
35
|
+
task_id: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.post("/{task_id}/input", response_model=InteractionResult)
|
|
39
|
+
async def respond_to_interaction(task_id: str, body: InteractionResponse):
|
|
40
|
+
"""
|
|
41
|
+
Respond to an input_required event.
|
|
42
|
+
|
|
43
|
+
This endpoint is called when the user responds to a permission request,
|
|
44
|
+
makes a choice, or provides text input.
|
|
45
|
+
|
|
46
|
+
The response type depends on the interaction kind:
|
|
47
|
+
- permission: bool (true = allow, false = deny)
|
|
48
|
+
- choice: int (selected index, -1 = cancelled)
|
|
49
|
+
- text: str or null (null = cancelled)
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
POST /api/tasks/task_123/input
|
|
53
|
+
{
|
|
54
|
+
"interaction_id": "int_456",
|
|
55
|
+
"response": true
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
# Find session containing this interaction
|
|
59
|
+
session = session_manager.find_session_by_interaction(body.interaction_id)
|
|
60
|
+
if not session:
|
|
61
|
+
raise HTTPException(
|
|
62
|
+
status_code=404, detail=f"Interaction {body.interaction_id} not found"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Verify task_id matches (optional validation)
|
|
66
|
+
if session.current_task_id and session.current_task_id != task_id:
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=400,
|
|
69
|
+
detail=f"Task ID mismatch: expected {session.current_task_id}, got {task_id}",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Resolve the interaction
|
|
73
|
+
resolved = session.adapter.resolve_interaction(body.interaction_id, body.response)
|
|
74
|
+
if not resolved:
|
|
75
|
+
raise HTTPException(
|
|
76
|
+
status_code=400,
|
|
77
|
+
detail=f"Interaction {body.interaction_id} already resolved or not found",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return InteractionResult(
|
|
81
|
+
status="ok", interaction_id=body.interaction_id, task_id=task_id
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/{task_id}/cancel")
|
|
86
|
+
async def cancel_interaction(task_id: str, interaction_id: str):
|
|
87
|
+
"""
|
|
88
|
+
Cancel a pending interaction.
|
|
89
|
+
|
|
90
|
+
Sets appropriate default value based on interaction type:
|
|
91
|
+
- permission: false (denied)
|
|
92
|
+
- choice: -1 (cancelled)
|
|
93
|
+
- text: null (cancelled)
|
|
94
|
+
"""
|
|
95
|
+
session = session_manager.find_session_by_interaction(interaction_id)
|
|
96
|
+
if not session:
|
|
97
|
+
raise HTTPException(
|
|
98
|
+
status_code=404, detail=f"Interaction {interaction_id} not found"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
cancelled = session.adapter.cancel_interaction(interaction_id)
|
|
102
|
+
if not cancelled:
|
|
103
|
+
raise HTTPException(
|
|
104
|
+
status_code=400,
|
|
105
|
+
detail=f"Interaction {interaction_id} already resolved or not found",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return {"status": "cancelled", "interaction_id": interaction_id, "task_id": task_id}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Alternative endpoint path for convenience
|
|
112
|
+
@router.post("/input/{interaction_id}")
|
|
113
|
+
async def respond_to_interaction_by_id(interaction_id: str, response: Any):
|
|
114
|
+
"""
|
|
115
|
+
Alternative endpoint to respond by interaction ID only.
|
|
116
|
+
|
|
117
|
+
Useful when task_id is not readily available.
|
|
118
|
+
"""
|
|
119
|
+
session = session_manager.find_session_by_interaction(interaction_id)
|
|
120
|
+
if not session:
|
|
121
|
+
raise HTTPException(
|
|
122
|
+
status_code=404, detail=f"Interaction {interaction_id} not found"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
resolved = session.adapter.resolve_interaction(interaction_id, response)
|
|
126
|
+
if not resolved:
|
|
127
|
+
raise HTTPException(
|
|
128
|
+
status_code=400,
|
|
129
|
+
detail=f"Interaction {interaction_id} already resolved or not found",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"status": "ok",
|
|
134
|
+
"interaction_id": interaction_id,
|
|
135
|
+
"task_id": session.current_task_id,
|
|
136
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Sessions API endpoints.
|
|
5
|
+
|
|
6
|
+
Handles session creation, retrieval, and management.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from typing import Optional, List, Dict, Any
|
|
12
|
+
|
|
13
|
+
from ..services.session_manager import session_manager, HistoryMode
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CreateSessionRequest(BaseModel):
|
|
19
|
+
"""Request body for creating a session."""
|
|
20
|
+
|
|
21
|
+
project_path: str = Field(
|
|
22
|
+
default=".", description="Working directory for the session"
|
|
23
|
+
)
|
|
24
|
+
history_mode: Optional[HistoryMode] = Field(
|
|
25
|
+
default=None,
|
|
26
|
+
description="History mode: 'full' (stateless) or 'incremental' (stateful)",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionResponse(BaseModel):
|
|
31
|
+
"""Response for session operations."""
|
|
32
|
+
|
|
33
|
+
session_id: str
|
|
34
|
+
project_path: str
|
|
35
|
+
history_mode: str
|
|
36
|
+
created_at: float
|
|
37
|
+
message_count: int = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SessionListResponse(BaseModel):
|
|
41
|
+
"""Response for listing sessions."""
|
|
42
|
+
|
|
43
|
+
sessions: List[Dict[str, Any]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.post("", response_model=SessionResponse)
|
|
47
|
+
async def create_session(request: CreateSessionRequest):
|
|
48
|
+
"""
|
|
49
|
+
Create a new session.
|
|
50
|
+
|
|
51
|
+
Returns a session ID that can be used for subsequent chat requests.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
session = await session_manager.create_session(
|
|
55
|
+
project_path=request.project_path, history_mode=request.history_mode
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return SessionResponse(
|
|
59
|
+
session_id=session.session_id,
|
|
60
|
+
project_path=session.project_path,
|
|
61
|
+
history_mode=session.history_mode,
|
|
62
|
+
created_at=session.created_at,
|
|
63
|
+
message_count=0,
|
|
64
|
+
)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("", response_model=SessionListResponse)
|
|
70
|
+
async def list_sessions():
|
|
71
|
+
"""List all active sessions."""
|
|
72
|
+
sessions = session_manager.list_sessions()
|
|
73
|
+
return SessionListResponse(sessions=sessions)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/{session_id}", response_model=SessionResponse)
|
|
77
|
+
async def get_session(session_id: str):
|
|
78
|
+
"""
|
|
79
|
+
Get session details.
|
|
80
|
+
|
|
81
|
+
Returns session info including message history.
|
|
82
|
+
"""
|
|
83
|
+
session = await session_manager.get_session(session_id)
|
|
84
|
+
if not session:
|
|
85
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
86
|
+
|
|
87
|
+
messages = session_manager.get_messages(session)
|
|
88
|
+
|
|
89
|
+
return SessionResponse(
|
|
90
|
+
session_id=session.session_id,
|
|
91
|
+
project_path=session.project_path,
|
|
92
|
+
history_mode=session.history_mode,
|
|
93
|
+
created_at=session.created_at,
|
|
94
|
+
message_count=len(messages),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("/{session_id}/messages")
|
|
99
|
+
async def get_session_messages(session_id: str):
|
|
100
|
+
"""
|
|
101
|
+
Get message history for a session.
|
|
102
|
+
|
|
103
|
+
Returns all messages in the conversation.
|
|
104
|
+
"""
|
|
105
|
+
session = await session_manager.get_session(session_id)
|
|
106
|
+
if not session:
|
|
107
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
108
|
+
|
|
109
|
+
messages = session_manager.get_messages(session)
|
|
110
|
+
|
|
111
|
+
return {"session_id": session_id, "messages": messages}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.delete("/{session_id}")
|
|
115
|
+
async def delete_session(session_id: str):
|
|
116
|
+
"""Delete a session."""
|
|
117
|
+
deleted = await session_manager.delete_session(session_id)
|
|
118
|
+
if not deleted:
|
|
119
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
120
|
+
|
|
121
|
+
return {"status": "ok", "session_id": session_id}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@router.post("/{session_id}/abort")
|
|
125
|
+
async def abort_session_task(session_id: str):
|
|
126
|
+
"""
|
|
127
|
+
Abort the current task in a session.
|
|
128
|
+
|
|
129
|
+
Sends abort signal to stop ongoing processing.
|
|
130
|
+
"""
|
|
131
|
+
aborted = await session_manager.abort_task(session_id)
|
|
132
|
+
if not aborted:
|
|
133
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
134
|
+
|
|
135
|
+
return {"status": "ok", "session_id": session_id}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
FastAPI Web Server for minion-code.
|
|
5
|
+
|
|
6
|
+
Provides HTTP/SSE API for cross-process frontend communication.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
|
|
15
|
+
from .api import chat_router, sessions_router, interactions_router
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app(
|
|
21
|
+
title: str = "Minion Code API",
|
|
22
|
+
version: str = "1.0.0",
|
|
23
|
+
cors_origins: Optional[list] = None,
|
|
24
|
+
) -> FastAPI:
|
|
25
|
+
"""
|
|
26
|
+
Create and configure FastAPI application.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
title: API title
|
|
30
|
+
version: API version
|
|
31
|
+
cors_origins: Allowed CORS origins (default: localhost:3000, localhost:5173)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Configured FastAPI application
|
|
35
|
+
"""
|
|
36
|
+
app = FastAPI(
|
|
37
|
+
title=title,
|
|
38
|
+
version=version,
|
|
39
|
+
description="""
|
|
40
|
+
Minion Code Web API
|
|
41
|
+
|
|
42
|
+
Provides streaming chat interface with:
|
|
43
|
+
- SSE (Server-Sent Events) for real-time responses
|
|
44
|
+
- A2A-style input_required for bidirectional interactions
|
|
45
|
+
- Session management with full/incremental history modes
|
|
46
|
+
|
|
47
|
+
## Endpoints
|
|
48
|
+
|
|
49
|
+
### Sessions
|
|
50
|
+
- `POST /api/sessions` - Create new session
|
|
51
|
+
- `GET /api/sessions` - List active sessions
|
|
52
|
+
- `GET /api/sessions/{id}` - Get session details
|
|
53
|
+
- `GET /api/sessions/{id}/messages` - Get message history
|
|
54
|
+
- `DELETE /api/sessions/{id}` - Delete session
|
|
55
|
+
- `POST /api/sessions/{id}/abort` - Abort current task
|
|
56
|
+
|
|
57
|
+
### Chat
|
|
58
|
+
- `POST /api/chat/{session_id}` - Send message, receive SSE stream
|
|
59
|
+
|
|
60
|
+
### Interactions
|
|
61
|
+
- `POST /api/tasks/{task_id}/input` - Respond to input_required event
|
|
62
|
+
- `POST /api/tasks/{task_id}/cancel` - Cancel pending interaction
|
|
63
|
+
|
|
64
|
+
## SSE Event Types
|
|
65
|
+
|
|
66
|
+
- `task_status` - Task state changes
|
|
67
|
+
- `content` - Streaming text content
|
|
68
|
+
- `thinking` - LLM reasoning content
|
|
69
|
+
- `tool_call` - Tool invocation
|
|
70
|
+
- `tool_result` - Tool execution result
|
|
71
|
+
- `input_required` - Request for user input
|
|
72
|
+
- `error` - Error message
|
|
73
|
+
|
|
74
|
+
## History Modes
|
|
75
|
+
|
|
76
|
+
- `full` - Each request creates new agent, loads full history (stateless, scalable)
|
|
77
|
+
- `incremental` - Reuse agent, only send new message (stateful, low latency)
|
|
78
|
+
""",
|
|
79
|
+
docs_url="/docs",
|
|
80
|
+
redoc_url="/redoc",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# CORS configuration
|
|
84
|
+
if cors_origins is None:
|
|
85
|
+
cors_origins = [
|
|
86
|
+
"http://localhost:3000",
|
|
87
|
+
"http://localhost:5173",
|
|
88
|
+
"http://127.0.0.1:3000",
|
|
89
|
+
"http://127.0.0.1:5173",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
app.add_middleware(
|
|
93
|
+
CORSMiddleware,
|
|
94
|
+
allow_origins=cors_origins,
|
|
95
|
+
allow_credentials=True,
|
|
96
|
+
allow_methods=["*"],
|
|
97
|
+
allow_headers=["*"],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Register routers
|
|
101
|
+
app.include_router(sessions_router)
|
|
102
|
+
app.include_router(chat_router)
|
|
103
|
+
app.include_router(interactions_router)
|
|
104
|
+
|
|
105
|
+
# Health check endpoint
|
|
106
|
+
@app.get("/health")
|
|
107
|
+
async def health_check():
|
|
108
|
+
return {"status": "ok"}
|
|
109
|
+
|
|
110
|
+
# Root endpoint
|
|
111
|
+
@app.get("/")
|
|
112
|
+
async def root():
|
|
113
|
+
return {"name": title, "version": version, "docs": "/docs", "health": "/health"}
|
|
114
|
+
|
|
115
|
+
return app
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_server(
|
|
119
|
+
host: str = "0.0.0.0",
|
|
120
|
+
port: int = 8000,
|
|
121
|
+
reload: bool = False,
|
|
122
|
+
log_level: str = "info",
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Run the web server.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
host: Host to bind to
|
|
129
|
+
port: Port to listen on
|
|
130
|
+
reload: Enable auto-reload for development
|
|
131
|
+
log_level: Logging level
|
|
132
|
+
"""
|
|
133
|
+
import uvicorn
|
|
134
|
+
|
|
135
|
+
logger.info(f"Starting Minion Code Web API on {host}:{port}")
|
|
136
|
+
|
|
137
|
+
uvicorn.run(
|
|
138
|
+
"minion_code.web.server:create_app",
|
|
139
|
+
factory=True,
|
|
140
|
+
host=host,
|
|
141
|
+
port=port,
|
|
142
|
+
reload=reload,
|
|
143
|
+
log_level=log_level,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# For direct module execution
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
run_server()
|