emdash-core 0.1.25__py3-none-any.whl → 0.1.37__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.
- emdash_core/agent/__init__.py +4 -0
- emdash_core/agent/agents.py +84 -23
- emdash_core/agent/events.py +42 -20
- emdash_core/agent/hooks.py +419 -0
- emdash_core/agent/inprocess_subagent.py +166 -18
- emdash_core/agent/prompts/__init__.py +4 -3
- emdash_core/agent/prompts/main_agent.py +67 -2
- emdash_core/agent/prompts/plan_mode.py +236 -107
- emdash_core/agent/prompts/subagents.py +103 -23
- emdash_core/agent/prompts/workflow.py +159 -26
- emdash_core/agent/providers/factory.py +2 -2
- emdash_core/agent/providers/openai_provider.py +67 -15
- emdash_core/agent/runner/__init__.py +49 -0
- emdash_core/agent/runner/agent_runner.py +765 -0
- emdash_core/agent/runner/context.py +470 -0
- emdash_core/agent/runner/factory.py +108 -0
- emdash_core/agent/runner/plan.py +217 -0
- emdash_core/agent/runner/sdk_runner.py +324 -0
- emdash_core/agent/runner/utils.py +67 -0
- emdash_core/agent/skills.py +47 -8
- emdash_core/agent/toolkit.py +46 -14
- emdash_core/agent/toolkits/__init__.py +117 -18
- emdash_core/agent/toolkits/base.py +87 -2
- emdash_core/agent/toolkits/explore.py +18 -0
- emdash_core/agent/toolkits/plan.py +27 -11
- emdash_core/agent/tools/__init__.py +2 -2
- emdash_core/agent/tools/coding.py +48 -4
- emdash_core/agent/tools/modes.py +151 -143
- emdash_core/agent/tools/task.py +52 -6
- emdash_core/api/agent.py +706 -1
- emdash_core/ingestion/repository.py +17 -198
- emdash_core/models/agent.py +4 -0
- emdash_core/skills/frontend-design/SKILL.md +56 -0
- emdash_core/sse/stream.py +4 -0
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/METADATA +4 -1
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/RECORD +38 -30
- emdash_core/agent/runner.py +0 -1123
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/entry_points.txt +0 -0
emdash_core/api/agent.py
CHANGED
|
@@ -31,6 +31,61 @@ def _ensure_emdash_importable():
|
|
|
31
31
|
pass # emdash_core is already in the package
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def _run_sdk_agent(
|
|
35
|
+
message: str,
|
|
36
|
+
model: str,
|
|
37
|
+
sse_handler: SSEHandler,
|
|
38
|
+
session_id: str,
|
|
39
|
+
emitter,
|
|
40
|
+
plan_mode: bool = False,
|
|
41
|
+
):
|
|
42
|
+
"""Run the agent using Anthropic Agent SDK.
|
|
43
|
+
|
|
44
|
+
This function is called when use_sdk=True and model is a Claude model.
|
|
45
|
+
It uses the SDKAgentRunner which provides native support for
|
|
46
|
+
Skills, MCP, and extended thinking.
|
|
47
|
+
"""
|
|
48
|
+
import asyncio
|
|
49
|
+
from ..agent.runner import SDKAgentRunner
|
|
50
|
+
|
|
51
|
+
# Get working directory from config
|
|
52
|
+
config = get_config()
|
|
53
|
+
cwd = str(config.repo_root) if config.repo_root else str(Path.cwd())
|
|
54
|
+
|
|
55
|
+
# Create SDK runner
|
|
56
|
+
runner = SDKAgentRunner(
|
|
57
|
+
model=model,
|
|
58
|
+
cwd=cwd,
|
|
59
|
+
emitter=emitter,
|
|
60
|
+
plan_mode=plan_mode,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Store session state
|
|
64
|
+
_sessions[session_id] = {
|
|
65
|
+
"runner": runner,
|
|
66
|
+
"message_count": 1,
|
|
67
|
+
"model": model,
|
|
68
|
+
"plan_mode": plan_mode,
|
|
69
|
+
"is_sdk": True,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Run async agent in sync context
|
|
73
|
+
async def run_async():
|
|
74
|
+
response_text = ""
|
|
75
|
+
async for event in runner.run(message):
|
|
76
|
+
if event.get("type") == "text":
|
|
77
|
+
response_text += event.get("content", "")
|
|
78
|
+
return response_text
|
|
79
|
+
|
|
80
|
+
# Run the async code
|
|
81
|
+
try:
|
|
82
|
+
response = asyncio.run(run_async())
|
|
83
|
+
return response
|
|
84
|
+
except Exception as e:
|
|
85
|
+
sse_handler.emit(EventType.ERROR, {"error": str(e)})
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
|
|
34
89
|
def _run_agent_sync(
|
|
35
90
|
message: str,
|
|
36
91
|
model: str,
|
|
@@ -38,19 +93,42 @@ def _run_agent_sync(
|
|
|
38
93
|
sse_handler: SSEHandler,
|
|
39
94
|
session_id: str,
|
|
40
95
|
images: list = None,
|
|
96
|
+
plan_mode: bool = False,
|
|
97
|
+
use_sdk: bool = None,
|
|
98
|
+
history: list = None,
|
|
41
99
|
):
|
|
42
100
|
"""Run the agent synchronously (in thread pool).
|
|
43
101
|
|
|
44
102
|
This function runs in a background thread and emits events
|
|
45
103
|
to the SSE handler for streaming to the client.
|
|
104
|
+
|
|
105
|
+
For Claude models with use_sdk=True, uses the Anthropic Agent SDK.
|
|
106
|
+
For other models, uses the standard AgentRunner with OpenAI-compatible API.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
history: Optional list of previous messages to pre-populate conversation
|
|
46
110
|
"""
|
|
47
111
|
try:
|
|
48
112
|
_ensure_emdash_importable()
|
|
49
113
|
|
|
50
114
|
# Import agent components from emdash_core
|
|
51
|
-
from ..agent.runner import AgentRunner
|
|
115
|
+
from ..agent.runner import AgentRunner, is_claude_model
|
|
116
|
+
from ..agent.toolkit import AgentToolkit
|
|
52
117
|
from ..agent.events import AgentEventEmitter
|
|
53
118
|
|
|
119
|
+
# Determine if we should use SDK
|
|
120
|
+
# Auto-detect based on model if not explicitly set
|
|
121
|
+
if use_sdk is None:
|
|
122
|
+
import os
|
|
123
|
+
# Check env var for SDK preference
|
|
124
|
+
sdk_enabled = os.environ.get("EMDASH_USE_SDK", "auto").lower()
|
|
125
|
+
if sdk_enabled == "true":
|
|
126
|
+
use_sdk = True
|
|
127
|
+
elif sdk_enabled == "false":
|
|
128
|
+
use_sdk = False
|
|
129
|
+
else: # "auto"
|
|
130
|
+
use_sdk = is_claude_model(model)
|
|
131
|
+
|
|
54
132
|
# Create an emitter that forwards to SSE handler
|
|
55
133
|
class SSEBridgeHandler:
|
|
56
134
|
"""Bridges AgentEventEmitter to SSEHandler."""
|
|
@@ -66,20 +144,92 @@ def _run_agent_sync(
|
|
|
66
144
|
emitter = AgentEventEmitter(agent_name="Emdash Code")
|
|
67
145
|
emitter.add_handler(SSEBridgeHandler(sse_handler))
|
|
68
146
|
|
|
147
|
+
# Add hook handler for user-defined hooks
|
|
148
|
+
from ..agent.hooks import HookHandler, get_hook_manager
|
|
149
|
+
hook_manager = get_hook_manager()
|
|
150
|
+
hook_manager.set_session_id(session_id)
|
|
151
|
+
emitter.add_handler(HookHandler(hook_manager))
|
|
152
|
+
|
|
153
|
+
# Use SDK for Claude models if enabled
|
|
154
|
+
if use_sdk and is_claude_model(model):
|
|
155
|
+
return _run_sdk_agent(
|
|
156
|
+
message=message,
|
|
157
|
+
model=model,
|
|
158
|
+
sse_handler=sse_handler,
|
|
159
|
+
session_id=session_id,
|
|
160
|
+
emitter=emitter,
|
|
161
|
+
plan_mode=plan_mode,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Standard path: use AgentRunner with OpenAI-compatible API
|
|
165
|
+
# Get repo_root from config (set by server on startup)
|
|
166
|
+
from pathlib import Path
|
|
167
|
+
from ..config import get_config
|
|
168
|
+
from ..utils.logger import log
|
|
169
|
+
config = get_config()
|
|
170
|
+
repo_root = Path(config.repo_root) if config.repo_root else Path.cwd()
|
|
171
|
+
log.info(f"Agent API: config.repo_root={config.repo_root}, resolved repo_root={repo_root}")
|
|
172
|
+
|
|
173
|
+
# Create toolkit with plan_mode if requested
|
|
174
|
+
# When in plan mode, generate a plan file path so write_to_file is available
|
|
175
|
+
plan_file_path = None
|
|
176
|
+
if plan_mode:
|
|
177
|
+
plan_file_path = str(repo_root / ".emdash" / "plan.md")
|
|
178
|
+
# Ensure .emdash directory exists
|
|
179
|
+
(repo_root / ".emdash").mkdir(exist_ok=True)
|
|
180
|
+
|
|
181
|
+
toolkit = AgentToolkit(repo_root=repo_root, plan_mode=plan_mode, plan_file_path=plan_file_path)
|
|
182
|
+
|
|
69
183
|
runner = AgentRunner(
|
|
184
|
+
toolkit=toolkit,
|
|
70
185
|
model=model,
|
|
71
186
|
verbose=True,
|
|
72
187
|
max_iterations=max_iterations,
|
|
73
188
|
emitter=emitter,
|
|
74
189
|
)
|
|
75
190
|
|
|
191
|
+
# Inject pre-loaded conversation history if provided
|
|
192
|
+
if history:
|
|
193
|
+
runner._messages = list(history)
|
|
194
|
+
log.info(f"Injected {len(history)} messages from saved session")
|
|
195
|
+
|
|
76
196
|
# Store session state BEFORE running (so it exists even if interrupted)
|
|
77
197
|
_sessions[session_id] = {
|
|
78
198
|
"runner": runner,
|
|
79
199
|
"message_count": 1,
|
|
80
200
|
"model": model,
|
|
201
|
+
"plan_mode": plan_mode,
|
|
81
202
|
}
|
|
82
203
|
|
|
204
|
+
# Set up autosave callback if enabled via env var
|
|
205
|
+
import os
|
|
206
|
+
import json
|
|
207
|
+
if os.environ.get("EMDASH_SESSION_AUTOSAVE", "").lower() == "true":
|
|
208
|
+
sessions_dir = repo_root / ".emdash" / "sessions"
|
|
209
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
autosave_path = sessions_dir / "_autosave.json"
|
|
211
|
+
|
|
212
|
+
def autosave_callback(messages):
|
|
213
|
+
"""Save messages to autosave file after each iteration."""
|
|
214
|
+
try:
|
|
215
|
+
# Limit to last 10 messages
|
|
216
|
+
trimmed = messages[-10:] if len(messages) > 10 else messages
|
|
217
|
+
autosave_data = {
|
|
218
|
+
"name": "_autosave",
|
|
219
|
+
"messages": trimmed,
|
|
220
|
+
"model": model,
|
|
221
|
+
"mode": "plan" if plan_mode else "code",
|
|
222
|
+
"session_id": session_id,
|
|
223
|
+
}
|
|
224
|
+
with open(autosave_path, "w") as f:
|
|
225
|
+
json.dump(autosave_data, f, indent=2, default=str)
|
|
226
|
+
log.debug(f"Autosaved {len(trimmed)} messages to {autosave_path}")
|
|
227
|
+
except Exception as e:
|
|
228
|
+
log.debug(f"Autosave failed: {e}")
|
|
229
|
+
|
|
230
|
+
runner._on_iteration_callback = autosave_callback
|
|
231
|
+
log.info("Session autosave enabled")
|
|
232
|
+
|
|
83
233
|
# Convert image data if provided
|
|
84
234
|
agent_images = None
|
|
85
235
|
if images:
|
|
@@ -114,6 +264,7 @@ async def _run_agent_async(
|
|
|
114
264
|
# Get model from request or config
|
|
115
265
|
model = request.model or config.default_model
|
|
116
266
|
max_iterations = request.options.max_iterations
|
|
267
|
+
plan_mode = request.options.mode == AgentMode.PLAN
|
|
117
268
|
|
|
118
269
|
# Emit session start
|
|
119
270
|
sse_handler.emit(EventType.SESSION_START, {
|
|
@@ -121,6 +272,7 @@ async def _run_agent_async(
|
|
|
121
272
|
"model": model,
|
|
122
273
|
"session_id": session_id,
|
|
123
274
|
"query": request.message,
|
|
275
|
+
"mode": request.options.mode.value,
|
|
124
276
|
})
|
|
125
277
|
|
|
126
278
|
loop = asyncio.get_event_loop()
|
|
@@ -136,6 +288,9 @@ async def _run_agent_async(
|
|
|
136
288
|
sse_handler,
|
|
137
289
|
session_id,
|
|
138
290
|
request.images,
|
|
291
|
+
plan_mode,
|
|
292
|
+
None, # use_sdk (auto-detect)
|
|
293
|
+
request.history, # Pre-loaded conversation history
|
|
139
294
|
)
|
|
140
295
|
|
|
141
296
|
# Emit session end
|
|
@@ -306,3 +461,553 @@ async def delete_session(session_id: str):
|
|
|
306
461
|
del _sessions[session_id]
|
|
307
462
|
return {"deleted": True}
|
|
308
463
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@router.get("/chat/{session_id}/export")
|
|
467
|
+
async def export_session(session_id: str, limit: int = 10):
|
|
468
|
+
"""Export session messages for persistence.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
session_id: The session ID
|
|
472
|
+
limit: Maximum number of messages to return (default 10)
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
JSON with messages array and metadata
|
|
476
|
+
"""
|
|
477
|
+
if session_id not in _sessions:
|
|
478
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
479
|
+
|
|
480
|
+
session = _sessions[session_id]
|
|
481
|
+
runner = session.get("runner")
|
|
482
|
+
|
|
483
|
+
if not runner:
|
|
484
|
+
return {
|
|
485
|
+
"session_id": session_id,
|
|
486
|
+
"messages": [],
|
|
487
|
+
"message_count": 0,
|
|
488
|
+
"model": session.get("model"),
|
|
489
|
+
"mode": "plan" if session.get("plan_mode") else "code",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
# Get messages from runner
|
|
493
|
+
messages = getattr(runner, "_messages", [])
|
|
494
|
+
|
|
495
|
+
# Trim to limit (most recent)
|
|
496
|
+
if len(messages) > limit:
|
|
497
|
+
messages = messages[-limit:]
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
"session_id": session_id,
|
|
501
|
+
"messages": messages,
|
|
502
|
+
"message_count": len(messages),
|
|
503
|
+
"model": session.get("model"),
|
|
504
|
+
"mode": "plan" if session.get("plan_mode") else "code",
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@router.get("/chat/{session_id}/plan")
|
|
509
|
+
async def get_pending_plan(session_id: str):
|
|
510
|
+
"""Get the pending plan for a session, if any.
|
|
511
|
+
|
|
512
|
+
Returns 404 if session not found, 204 if no pending plan.
|
|
513
|
+
"""
|
|
514
|
+
if session_id not in _sessions:
|
|
515
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
516
|
+
|
|
517
|
+
session = _sessions[session_id]
|
|
518
|
+
runner = session.get("runner")
|
|
519
|
+
|
|
520
|
+
if not runner:
|
|
521
|
+
raise HTTPException(status_code=400, detail="Session has no active runner")
|
|
522
|
+
|
|
523
|
+
pending_plan = runner.get_pending_plan()
|
|
524
|
+
if not pending_plan:
|
|
525
|
+
return {"has_plan": False, "plan": None}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
"has_plan": True,
|
|
529
|
+
"session_id": session_id,
|
|
530
|
+
"plan": pending_plan,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@router.post("/chat/{session_id}/plan/approve")
|
|
535
|
+
async def approve_plan(session_id: str):
|
|
536
|
+
"""Approve the pending plan and transition to code mode.
|
|
537
|
+
|
|
538
|
+
Returns SSE stream for the implementation phase.
|
|
539
|
+
"""
|
|
540
|
+
if session_id not in _sessions:
|
|
541
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
542
|
+
|
|
543
|
+
session = _sessions[session_id]
|
|
544
|
+
runner = session.get("runner")
|
|
545
|
+
|
|
546
|
+
if not runner:
|
|
547
|
+
raise HTTPException(status_code=400, detail="Session has no active runner")
|
|
548
|
+
|
|
549
|
+
if not runner.has_pending_plan():
|
|
550
|
+
raise HTTPException(status_code=400, detail="No pending plan to approve")
|
|
551
|
+
|
|
552
|
+
# Create SSE handler for streaming the implementation
|
|
553
|
+
sse_handler = SSEHandler(agent_name="Emdash Code")
|
|
554
|
+
|
|
555
|
+
async def _run_approval():
|
|
556
|
+
loop = asyncio.get_event_loop()
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
# Wire up SSE handler
|
|
560
|
+
from ..agent.events import AgentEventEmitter
|
|
561
|
+
|
|
562
|
+
class SSEBridgeHandler:
|
|
563
|
+
def __init__(self, sse_handler: SSEHandler):
|
|
564
|
+
self._sse = sse_handler
|
|
565
|
+
|
|
566
|
+
def handle(self, event):
|
|
567
|
+
self._sse.handle(event)
|
|
568
|
+
|
|
569
|
+
emitter = AgentEventEmitter(agent_name="Emdash Code")
|
|
570
|
+
emitter.add_handler(SSEBridgeHandler(sse_handler))
|
|
571
|
+
runner.emitter = emitter
|
|
572
|
+
|
|
573
|
+
sse_handler.emit(EventType.SESSION_START, {
|
|
574
|
+
"agent_name": "Emdash Code",
|
|
575
|
+
"model": session.get("model", "unknown"),
|
|
576
|
+
"session_id": session_id,
|
|
577
|
+
"query": "Plan approved - implementing...",
|
|
578
|
+
"plan_approved": True,
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
# Reset cycle state for new mode
|
|
582
|
+
from ..agent.tools.modes import ModeState
|
|
583
|
+
ModeState.get_instance().reset_cycle()
|
|
584
|
+
|
|
585
|
+
# Approve and run implementation
|
|
586
|
+
await loop.run_in_executor(
|
|
587
|
+
_executor,
|
|
588
|
+
runner.approve_plan,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
session["plan_mode"] = False # Now in code mode
|
|
592
|
+
|
|
593
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
594
|
+
"success": True,
|
|
595
|
+
"session_id": session_id,
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
except Exception as e:
|
|
599
|
+
sse_handler.emit(EventType.ERROR, {"message": str(e)})
|
|
600
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
601
|
+
"success": False,
|
|
602
|
+
"error": str(e),
|
|
603
|
+
"session_id": session_id,
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
finally:
|
|
607
|
+
sse_handler.close()
|
|
608
|
+
|
|
609
|
+
asyncio.create_task(_run_approval())
|
|
610
|
+
|
|
611
|
+
return StreamingResponse(
|
|
612
|
+
sse_handler,
|
|
613
|
+
media_type="text/event-stream",
|
|
614
|
+
headers={
|
|
615
|
+
"Cache-Control": "no-cache",
|
|
616
|
+
"Connection": "keep-alive",
|
|
617
|
+
"X-Session-ID": session_id,
|
|
618
|
+
},
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@router.post("/chat/{session_id}/plan/reject")
|
|
623
|
+
async def reject_plan(session_id: str, feedback: str = ""):
|
|
624
|
+
"""Reject the pending plan with feedback.
|
|
625
|
+
|
|
626
|
+
Returns SSE stream for the revised planning phase.
|
|
627
|
+
"""
|
|
628
|
+
if session_id not in _sessions:
|
|
629
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
630
|
+
|
|
631
|
+
session = _sessions[session_id]
|
|
632
|
+
runner = session.get("runner")
|
|
633
|
+
|
|
634
|
+
if not runner:
|
|
635
|
+
raise HTTPException(status_code=400, detail="Session has no active runner")
|
|
636
|
+
|
|
637
|
+
if not runner.has_pending_plan():
|
|
638
|
+
raise HTTPException(status_code=400, detail="No pending plan to reject")
|
|
639
|
+
|
|
640
|
+
sse_handler = SSEHandler(agent_name="Emdash Code")
|
|
641
|
+
|
|
642
|
+
async def _run_rejection():
|
|
643
|
+
loop = asyncio.get_event_loop()
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
from ..agent.events import AgentEventEmitter
|
|
647
|
+
|
|
648
|
+
class SSEBridgeHandler:
|
|
649
|
+
def __init__(self, sse_handler: SSEHandler):
|
|
650
|
+
self._sse = sse_handler
|
|
651
|
+
|
|
652
|
+
def handle(self, event):
|
|
653
|
+
self._sse.handle(event)
|
|
654
|
+
|
|
655
|
+
emitter = AgentEventEmitter(agent_name="Emdash Code")
|
|
656
|
+
emitter.add_handler(SSEBridgeHandler(sse_handler))
|
|
657
|
+
runner.emitter = emitter
|
|
658
|
+
|
|
659
|
+
sse_handler.emit(EventType.SESSION_START, {
|
|
660
|
+
"agent_name": "Emdash Code",
|
|
661
|
+
"model": session.get("model", "unknown"),
|
|
662
|
+
"session_id": session_id,
|
|
663
|
+
"query": f"Plan rejected - revising... {feedback}",
|
|
664
|
+
"plan_rejected": True,
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
# Reset cycle state for revision
|
|
668
|
+
from ..agent.tools.modes import ModeState
|
|
669
|
+
ModeState.get_instance().reset_cycle()
|
|
670
|
+
|
|
671
|
+
# Reject and continue planning
|
|
672
|
+
await loop.run_in_executor(
|
|
673
|
+
_executor,
|
|
674
|
+
lambda: runner.reject_plan(feedback),
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
678
|
+
"success": True,
|
|
679
|
+
"session_id": session_id,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
except Exception as e:
|
|
683
|
+
sse_handler.emit(EventType.ERROR, {"message": str(e)})
|
|
684
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
685
|
+
"success": False,
|
|
686
|
+
"error": str(e),
|
|
687
|
+
"session_id": session_id,
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
finally:
|
|
691
|
+
sse_handler.close()
|
|
692
|
+
|
|
693
|
+
asyncio.create_task(_run_rejection())
|
|
694
|
+
|
|
695
|
+
return StreamingResponse(
|
|
696
|
+
sse_handler,
|
|
697
|
+
media_type="text/event-stream",
|
|
698
|
+
headers={
|
|
699
|
+
"Cache-Control": "no-cache",
|
|
700
|
+
"Connection": "keep-alive",
|
|
701
|
+
"X-Session-ID": session_id,
|
|
702
|
+
},
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@router.post("/chat/{session_id}/planmode/approve")
|
|
707
|
+
async def approve_plan_mode(session_id: str):
|
|
708
|
+
"""Approve entering plan mode.
|
|
709
|
+
|
|
710
|
+
Called when user approves the agent's request to enter plan mode.
|
|
711
|
+
Returns SSE stream for the planning phase.
|
|
712
|
+
"""
|
|
713
|
+
if session_id not in _sessions:
|
|
714
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
715
|
+
|
|
716
|
+
session = _sessions[session_id]
|
|
717
|
+
runner = session.get("runner")
|
|
718
|
+
|
|
719
|
+
if not runner:
|
|
720
|
+
raise HTTPException(status_code=400, detail="Session has no active runner")
|
|
721
|
+
|
|
722
|
+
# Check if plan mode was actually requested
|
|
723
|
+
from ..agent.tools.modes import ModeState
|
|
724
|
+
state = ModeState.get_instance()
|
|
725
|
+
if not state.plan_mode_requested:
|
|
726
|
+
raise HTTPException(status_code=400, detail="No pending plan mode request")
|
|
727
|
+
|
|
728
|
+
sse_handler = SSEHandler(agent_name="Emdash Code")
|
|
729
|
+
|
|
730
|
+
async def _run_approval():
|
|
731
|
+
loop = asyncio.get_event_loop()
|
|
732
|
+
|
|
733
|
+
try:
|
|
734
|
+
from ..agent.events import AgentEventEmitter
|
|
735
|
+
|
|
736
|
+
class SSEBridgeHandler:
|
|
737
|
+
def __init__(self, sse_handler: SSEHandler):
|
|
738
|
+
self._sse = sse_handler
|
|
739
|
+
|
|
740
|
+
def handle(self, event):
|
|
741
|
+
self._sse.handle(event)
|
|
742
|
+
|
|
743
|
+
emitter = AgentEventEmitter(agent_name="Emdash Code")
|
|
744
|
+
emitter.add_handler(SSEBridgeHandler(sse_handler))
|
|
745
|
+
runner.emitter = emitter
|
|
746
|
+
|
|
747
|
+
sse_handler.emit(EventType.SESSION_START, {
|
|
748
|
+
"agent_name": "Emdash Code",
|
|
749
|
+
"model": session.get("model", "unknown"),
|
|
750
|
+
"session_id": session_id,
|
|
751
|
+
"query": "Plan mode approved - entering plan mode...",
|
|
752
|
+
"plan_mode_approved": True,
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
# Approve and enter plan mode
|
|
756
|
+
await loop.run_in_executor(
|
|
757
|
+
_executor,
|
|
758
|
+
runner.approve_plan_mode,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
762
|
+
"success": True,
|
|
763
|
+
"session_id": session_id,
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
except Exception as e:
|
|
767
|
+
sse_handler.emit(EventType.ERROR, {"message": str(e)})
|
|
768
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
769
|
+
"success": False,
|
|
770
|
+
"error": str(e),
|
|
771
|
+
"session_id": session_id,
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
finally:
|
|
775
|
+
sse_handler.close()
|
|
776
|
+
|
|
777
|
+
asyncio.create_task(_run_approval())
|
|
778
|
+
|
|
779
|
+
return StreamingResponse(
|
|
780
|
+
sse_handler,
|
|
781
|
+
media_type="text/event-stream",
|
|
782
|
+
headers={
|
|
783
|
+
"Cache-Control": "no-cache",
|
|
784
|
+
"Connection": "keep-alive",
|
|
785
|
+
"X-Session-ID": session_id,
|
|
786
|
+
},
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@router.post("/chat/{session_id}/clarification/answer")
|
|
791
|
+
async def answer_clarification(session_id: str, answer: str):
|
|
792
|
+
"""Answer a pending clarification question.
|
|
793
|
+
|
|
794
|
+
Called when the user responds to a clarification question asked by the agent
|
|
795
|
+
via ask_followup_question tool. This resumes the agent with the user's answer.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
session_id: The session ID
|
|
799
|
+
answer: The user's answer to the clarification question
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
SSE stream for the agent's continued execution
|
|
803
|
+
"""
|
|
804
|
+
if session_id not in _sessions:
|
|
805
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
806
|
+
|
|
807
|
+
session = _sessions[session_id]
|
|
808
|
+
runner = session.get("runner")
|
|
809
|
+
|
|
810
|
+
if not runner:
|
|
811
|
+
raise HTTPException(status_code=400, detail="Session has no active runner")
|
|
812
|
+
|
|
813
|
+
sse_handler = SSEHandler(agent_name="Emdash Code")
|
|
814
|
+
|
|
815
|
+
async def _run_answer():
|
|
816
|
+
loop = asyncio.get_event_loop()
|
|
817
|
+
|
|
818
|
+
try:
|
|
819
|
+
from ..agent.events import AgentEventEmitter
|
|
820
|
+
|
|
821
|
+
class SSEBridgeHandler:
|
|
822
|
+
def __init__(self, sse_handler: SSEHandler):
|
|
823
|
+
self._sse = sse_handler
|
|
824
|
+
|
|
825
|
+
def handle(self, event):
|
|
826
|
+
self._sse.handle(event)
|
|
827
|
+
|
|
828
|
+
emitter = AgentEventEmitter(agent_name="Emdash Code")
|
|
829
|
+
emitter.add_handler(SSEBridgeHandler(sse_handler))
|
|
830
|
+
runner.emitter = emitter
|
|
831
|
+
|
|
832
|
+
sse_handler.emit(EventType.SESSION_START, {
|
|
833
|
+
"agent_name": "Emdash Code",
|
|
834
|
+
"model": session.get("model", "unknown"),
|
|
835
|
+
"session_id": session_id,
|
|
836
|
+
"query": f"Clarification answered: {answer[:100]}...",
|
|
837
|
+
"clarification_answered": True,
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
# Answer the clarification and resume the agent
|
|
841
|
+
await loop.run_in_executor(
|
|
842
|
+
_executor,
|
|
843
|
+
lambda: runner.answer_clarification(answer),
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
847
|
+
"success": True,
|
|
848
|
+
"session_id": session_id,
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
except Exception as e:
|
|
852
|
+
sse_handler.emit(EventType.ERROR, {"message": str(e)})
|
|
853
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
854
|
+
"success": False,
|
|
855
|
+
"error": str(e),
|
|
856
|
+
"session_id": session_id,
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
finally:
|
|
860
|
+
sse_handler.close()
|
|
861
|
+
|
|
862
|
+
asyncio.create_task(_run_answer())
|
|
863
|
+
|
|
864
|
+
return StreamingResponse(
|
|
865
|
+
sse_handler,
|
|
866
|
+
media_type="text/event-stream",
|
|
867
|
+
headers={
|
|
868
|
+
"Cache-Control": "no-cache",
|
|
869
|
+
"Connection": "keep-alive",
|
|
870
|
+
"X-Session-ID": session_id,
|
|
871
|
+
},
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
@router.post("/chat/{session_id}/planmode/reject")
|
|
876
|
+
async def reject_plan_mode(session_id: str, feedback: str = ""):
|
|
877
|
+
"""Reject entering plan mode.
|
|
878
|
+
|
|
879
|
+
Called when user rejects the agent's request to enter plan mode.
|
|
880
|
+
Returns SSE stream for continued code mode execution.
|
|
881
|
+
"""
|
|
882
|
+
if session_id not in _sessions:
|
|
883
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
884
|
+
|
|
885
|
+
session = _sessions[session_id]
|
|
886
|
+
runner = session.get("runner")
|
|
887
|
+
|
|
888
|
+
if not runner:
|
|
889
|
+
raise HTTPException(status_code=400, detail="Session has no active runner")
|
|
890
|
+
|
|
891
|
+
# Check if plan mode was actually requested
|
|
892
|
+
from ..agent.tools.modes import ModeState
|
|
893
|
+
state = ModeState.get_instance()
|
|
894
|
+
if not state.plan_mode_requested:
|
|
895
|
+
raise HTTPException(status_code=400, detail="No pending plan mode request")
|
|
896
|
+
|
|
897
|
+
sse_handler = SSEHandler(agent_name="Emdash Code")
|
|
898
|
+
|
|
899
|
+
async def _run_rejection():
|
|
900
|
+
loop = asyncio.get_event_loop()
|
|
901
|
+
|
|
902
|
+
try:
|
|
903
|
+
from ..agent.events import AgentEventEmitter
|
|
904
|
+
|
|
905
|
+
class SSEBridgeHandler:
|
|
906
|
+
def __init__(self, sse_handler: SSEHandler):
|
|
907
|
+
self._sse = sse_handler
|
|
908
|
+
|
|
909
|
+
def handle(self, event):
|
|
910
|
+
self._sse.handle(event)
|
|
911
|
+
|
|
912
|
+
emitter = AgentEventEmitter(agent_name="Emdash Code")
|
|
913
|
+
emitter.add_handler(SSEBridgeHandler(sse_handler))
|
|
914
|
+
runner.emitter = emitter
|
|
915
|
+
|
|
916
|
+
sse_handler.emit(EventType.SESSION_START, {
|
|
917
|
+
"agent_name": "Emdash Code",
|
|
918
|
+
"model": session.get("model", "unknown"),
|
|
919
|
+
"session_id": session_id,
|
|
920
|
+
"query": f"Plan mode rejected - continuing in code mode... {feedback}",
|
|
921
|
+
"plan_mode_rejected": True,
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
# Reject and stay in code mode
|
|
925
|
+
await loop.run_in_executor(
|
|
926
|
+
_executor,
|
|
927
|
+
lambda: runner.reject_plan_mode(feedback),
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
931
|
+
"success": True,
|
|
932
|
+
"session_id": session_id,
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
except Exception as e:
|
|
936
|
+
sse_handler.emit(EventType.ERROR, {"message": str(e)})
|
|
937
|
+
sse_handler.emit(EventType.SESSION_END, {
|
|
938
|
+
"success": False,
|
|
939
|
+
"error": str(e),
|
|
940
|
+
"session_id": session_id,
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
finally:
|
|
944
|
+
sse_handler.close()
|
|
945
|
+
|
|
946
|
+
asyncio.create_task(_run_rejection())
|
|
947
|
+
|
|
948
|
+
return StreamingResponse(
|
|
949
|
+
sse_handler,
|
|
950
|
+
media_type="text/event-stream",
|
|
951
|
+
headers={
|
|
952
|
+
"Cache-Control": "no-cache",
|
|
953
|
+
"Connection": "keep-alive",
|
|
954
|
+
"X-Session-ID": session_id,
|
|
955
|
+
},
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
@router.get("/chat/{session_id}/todos")
|
|
960
|
+
async def get_todos(session_id: str):
|
|
961
|
+
"""Get the current todo list for a session.
|
|
962
|
+
|
|
963
|
+
Returns the agent's task list including status of each item.
|
|
964
|
+
"""
|
|
965
|
+
if session_id not in _sessions:
|
|
966
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
967
|
+
|
|
968
|
+
# Get todos from TaskState singleton
|
|
969
|
+
from ..agent.tools.tasks import TaskState
|
|
970
|
+
state = TaskState.get_instance()
|
|
971
|
+
|
|
972
|
+
todos = state.get_all_tasks()
|
|
973
|
+
|
|
974
|
+
# Count by status
|
|
975
|
+
pending = sum(1 for t in todos if t["status"] == "pending")
|
|
976
|
+
in_progress = sum(1 for t in todos if t["status"] == "in_progress")
|
|
977
|
+
completed = sum(1 for t in todos if t["status"] == "completed")
|
|
978
|
+
|
|
979
|
+
return {
|
|
980
|
+
"session_id": session_id,
|
|
981
|
+
"todos": todos,
|
|
982
|
+
"summary": {
|
|
983
|
+
"total": len(todos),
|
|
984
|
+
"pending": pending,
|
|
985
|
+
"in_progress": in_progress,
|
|
986
|
+
"completed": completed,
|
|
987
|
+
},
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@router.post("/chat/{session_id}/todos")
|
|
992
|
+
async def add_todo(session_id: str, title: str, description: str = ""):
|
|
993
|
+
"""Add a new todo item to the agent's task list.
|
|
994
|
+
|
|
995
|
+
This allows users to inject tasks for the agent to work on.
|
|
996
|
+
"""
|
|
997
|
+
if session_id not in _sessions:
|
|
998
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
999
|
+
|
|
1000
|
+
if not title or not title.strip():
|
|
1001
|
+
raise HTTPException(status_code=400, detail="Title is required")
|
|
1002
|
+
|
|
1003
|
+
# Add todo via TaskState singleton
|
|
1004
|
+
from ..agent.tools.tasks import TaskState
|
|
1005
|
+
state = TaskState.get_instance()
|
|
1006
|
+
|
|
1007
|
+
task = state.add_task(title=title.strip(), description=description.strip())
|
|
1008
|
+
|
|
1009
|
+
return {
|
|
1010
|
+
"session_id": session_id,
|
|
1011
|
+
"task": task.to_dict(),
|
|
1012
|
+
"total_tasks": len(state.tasks),
|
|
1013
|
+
}
|