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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Plan management mixin for the agent runner.
|
|
2
|
+
|
|
3
|
+
This module provides plan approval/rejection functionality as a mixin class.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..toolkit import AgentToolkit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlanMixin:
|
|
14
|
+
"""Mixin class providing plan management methods for AgentRunner.
|
|
15
|
+
|
|
16
|
+
This mixin expects the following attributes on the class:
|
|
17
|
+
- _pending_plan: Optional[dict] - stores pending plan
|
|
18
|
+
- toolkit: AgentToolkit - the agent's toolkit
|
|
19
|
+
- emitter: AgentEventEmitter - event emitter
|
|
20
|
+
- system_prompt: str - current system prompt
|
|
21
|
+
|
|
22
|
+
And the following methods:
|
|
23
|
+
- run(message: str) -> str - to continue execution
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_pending_plan: Optional[dict]
|
|
27
|
+
|
|
28
|
+
def _get_plan_file_path(self) -> str:
|
|
29
|
+
"""Get the plan file path based on repo root.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Path to the plan file (e.g., .emdash/plan.md)
|
|
33
|
+
"""
|
|
34
|
+
repo_root = self.toolkit._repo_root
|
|
35
|
+
return str(repo_root / ".emdash" / "plan.md")
|
|
36
|
+
|
|
37
|
+
def has_pending_plan(self) -> bool:
|
|
38
|
+
"""Check if there's a plan awaiting approval.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if a plan has been submitted and is awaiting approval.
|
|
42
|
+
"""
|
|
43
|
+
return self._pending_plan is not None
|
|
44
|
+
|
|
45
|
+
def get_pending_plan(self) -> Optional[dict]:
|
|
46
|
+
"""Get the pending plan if one exists.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The pending plan dict, or None if no plan is pending.
|
|
50
|
+
"""
|
|
51
|
+
return self._pending_plan
|
|
52
|
+
|
|
53
|
+
def approve_plan(self) -> str:
|
|
54
|
+
"""Approve the pending plan and transition back to code mode.
|
|
55
|
+
|
|
56
|
+
This method should be called after the user approves a submitted plan.
|
|
57
|
+
It transitions the agent from plan mode back to code mode, allowing
|
|
58
|
+
it to implement the approved plan.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The agent's response after transitioning to code mode.
|
|
62
|
+
"""
|
|
63
|
+
if not self._pending_plan:
|
|
64
|
+
return "No pending plan to approve."
|
|
65
|
+
|
|
66
|
+
plan_content = self._pending_plan.get("plan", "")
|
|
67
|
+
plan_file_path = None
|
|
68
|
+
|
|
69
|
+
# Try to get plan file path for reference in the approval message
|
|
70
|
+
from ..tools.modes import ModeState, AgentMode
|
|
71
|
+
state = ModeState.get_instance()
|
|
72
|
+
plan_file_path = state.get_plan_file_path()
|
|
73
|
+
|
|
74
|
+
self._pending_plan = None # Clear pending plan
|
|
75
|
+
|
|
76
|
+
# Reset ModeState singleton to code mode
|
|
77
|
+
state.current_mode = AgentMode.CODE
|
|
78
|
+
state.plan_content = plan_content
|
|
79
|
+
state.plan_file_path = None # Clear plan file path
|
|
80
|
+
|
|
81
|
+
# Import AgentToolkit here to avoid circular imports
|
|
82
|
+
from ..toolkit import AgentToolkit
|
|
83
|
+
from ..prompts import build_system_prompt
|
|
84
|
+
|
|
85
|
+
# Rebuild toolkit with plan_mode=False (code mode)
|
|
86
|
+
self.toolkit = AgentToolkit(
|
|
87
|
+
connection=self.toolkit.connection,
|
|
88
|
+
repo_root=self.toolkit._repo_root,
|
|
89
|
+
plan_mode=False,
|
|
90
|
+
)
|
|
91
|
+
self.toolkit.set_emitter(self.emitter)
|
|
92
|
+
|
|
93
|
+
# Update system prompt back to code mode
|
|
94
|
+
self.system_prompt = build_system_prompt(self.toolkit)
|
|
95
|
+
|
|
96
|
+
# Resume execution with approval message
|
|
97
|
+
plan_reference = f"(Plan file: {plan_file_path})" if plan_file_path else ""
|
|
98
|
+
approval_message = f"""Your plan has been APPROVED. {plan_reference}
|
|
99
|
+
|
|
100
|
+
You are now in code mode. Implement the following plan:
|
|
101
|
+
|
|
102
|
+
{plan_content}
|
|
103
|
+
|
|
104
|
+
Proceed with implementation step by step using the available tools."""
|
|
105
|
+
|
|
106
|
+
return self.run(approval_message)
|
|
107
|
+
|
|
108
|
+
def reject_plan(self, feedback: str = "") -> str:
|
|
109
|
+
"""Reject the pending plan and provide feedback.
|
|
110
|
+
|
|
111
|
+
The agent remains in plan mode to revise the plan based on feedback.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
feedback: Optional feedback explaining why the plan was rejected.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The agent's response after receiving the rejection.
|
|
118
|
+
"""
|
|
119
|
+
if not self._pending_plan:
|
|
120
|
+
return "No pending plan to reject."
|
|
121
|
+
|
|
122
|
+
plan_title = self._pending_plan.get("title", "Untitled")
|
|
123
|
+
self._pending_plan = None # Clear pending plan (but stay in plan mode)
|
|
124
|
+
|
|
125
|
+
rejection_message = f"""Your plan "{plan_title}" was REJECTED.
|
|
126
|
+
|
|
127
|
+
{f"Feedback: {feedback}" if feedback else "Please revise the plan."}
|
|
128
|
+
|
|
129
|
+
You are still in plan mode. Please address the feedback and submit a revised plan using exit_plan."""
|
|
130
|
+
|
|
131
|
+
return self.run(rejection_message)
|
|
132
|
+
|
|
133
|
+
def approve_plan_mode(self) -> str:
|
|
134
|
+
"""Approve entering plan mode.
|
|
135
|
+
|
|
136
|
+
This method should be called after the user approves a plan mode request
|
|
137
|
+
(triggered by enter_plan_mode tool). It transitions the agent into plan mode.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The agent's response after entering plan mode.
|
|
141
|
+
"""
|
|
142
|
+
from ..tools.modes import ModeState
|
|
143
|
+
state = ModeState.get_instance()
|
|
144
|
+
|
|
145
|
+
if not state.plan_mode_requested:
|
|
146
|
+
return "No pending plan mode request."
|
|
147
|
+
|
|
148
|
+
reason = state.plan_mode_reason or ""
|
|
149
|
+
|
|
150
|
+
# Set the plan file path before approving
|
|
151
|
+
plan_file_path = self._get_plan_file_path()
|
|
152
|
+
state.set_plan_file_path(plan_file_path)
|
|
153
|
+
|
|
154
|
+
# Actually enter plan mode
|
|
155
|
+
state.approve_plan_mode()
|
|
156
|
+
|
|
157
|
+
# Import here to avoid circular imports
|
|
158
|
+
from ..toolkit import AgentToolkit
|
|
159
|
+
from ..prompts import build_system_prompt
|
|
160
|
+
|
|
161
|
+
# Rebuild toolkit with plan_mode=True and plan_file_path
|
|
162
|
+
self.toolkit = AgentToolkit(
|
|
163
|
+
connection=self.toolkit.connection,
|
|
164
|
+
repo_root=self.toolkit._repo_root,
|
|
165
|
+
plan_mode=True,
|
|
166
|
+
plan_file_path=plan_file_path,
|
|
167
|
+
)
|
|
168
|
+
self.toolkit.set_emitter(self.emitter)
|
|
169
|
+
|
|
170
|
+
# Main agent uses normal prompt - it delegates to Plan subagent
|
|
171
|
+
self.system_prompt = build_system_prompt(self.toolkit)
|
|
172
|
+
|
|
173
|
+
# Resume execution - tell main agent to spawn Plan subagent
|
|
174
|
+
approval_message = f"""Your request to enter plan mode has been APPROVED.
|
|
175
|
+
|
|
176
|
+
Reason: {reason}
|
|
177
|
+
|
|
178
|
+
You are now in plan mode. Follow these steps:
|
|
179
|
+
|
|
180
|
+
1. **Spawn Plan subagent NOW**:
|
|
181
|
+
`task(subagent_type="Plan", prompt="<your planning request>")`
|
|
182
|
+
|
|
183
|
+
2. **After the Plan subagent returns**, take its response (the plan content) and:
|
|
184
|
+
a) Write it to `{plan_file_path}` using `write_to_file(path="{plan_file_path}", content=<plan>)`
|
|
185
|
+
b) Call `exit_plan()` to present the plan for user approval
|
|
186
|
+
|
|
187
|
+
Start by spawning the Plan subagent."""
|
|
188
|
+
|
|
189
|
+
return self.run(approval_message)
|
|
190
|
+
|
|
191
|
+
def reject_plan_mode(self, feedback: str = "") -> str:
|
|
192
|
+
"""Reject entering plan mode.
|
|
193
|
+
|
|
194
|
+
The agent remains in code mode and continues with the task.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
feedback: Optional feedback explaining why plan mode was rejected.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The agent's response after rejection.
|
|
201
|
+
"""
|
|
202
|
+
from ..tools.modes import ModeState
|
|
203
|
+
state = ModeState.get_instance()
|
|
204
|
+
|
|
205
|
+
if not state.plan_mode_requested:
|
|
206
|
+
return "No pending plan mode request."
|
|
207
|
+
|
|
208
|
+
# Reset the request
|
|
209
|
+
state.reject_plan_mode()
|
|
210
|
+
|
|
211
|
+
rejection_message = f"""Your request to enter plan mode was REJECTED.
|
|
212
|
+
|
|
213
|
+
{f"Feedback: {feedback}" if feedback else "The user prefers to proceed without detailed planning."}
|
|
214
|
+
|
|
215
|
+
You are still in code mode. Please proceed with the task directly, or ask for clarification if needed."""
|
|
216
|
+
|
|
217
|
+
return self.run(rejection_message)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""SDK Agent Runner - Uses Anthropic Agent SDK for Claude models.
|
|
2
|
+
|
|
3
|
+
This module provides an alternative runner that uses the official Anthropic
|
|
4
|
+
Agent SDK (claude-agent-sdk) for Claude models. This enables native support
|
|
5
|
+
for Skills, MCP tools, and extended thinking.
|
|
6
|
+
|
|
7
|
+
For non-Claude models (Fireworks, OpenAI, etc.), use the standard AgentRunner.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import AsyncIterator, Optional, TYPE_CHECKING
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from ...utils.logger import log
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ..events import AgentEventEmitter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SDKAgentRunner:
|
|
21
|
+
"""Agent runner using Anthropic Agent SDK directly.
|
|
22
|
+
|
|
23
|
+
This runner uses the official claude-agent-sdk package which bundles
|
|
24
|
+
Claude Code CLI. It provides native support for:
|
|
25
|
+
- Skills (.claude/skills/)
|
|
26
|
+
- MCP servers (in-process and external)
|
|
27
|
+
- Extended thinking
|
|
28
|
+
- Built-in tools (Read, Write, Bash, Glob, Grep)
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
runner = SDKAgentRunner(
|
|
32
|
+
model="claude-sonnet-4-20250514",
|
|
33
|
+
cwd="/path/to/project",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
async for event in runner.run("Find authentication code"):
|
|
37
|
+
print(event)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Default model for SDK (must be a Claude model)
|
|
41
|
+
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
model: str = DEFAULT_MODEL,
|
|
46
|
+
cwd: Optional[str] = None,
|
|
47
|
+
emitter: Optional["AgentEventEmitter"] = None,
|
|
48
|
+
system_prompt: Optional[str] = None,
|
|
49
|
+
plan_mode: bool = False,
|
|
50
|
+
):
|
|
51
|
+
"""Initialize the SDK runner.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
model: Claude model to use (e.g., "claude-sonnet-4-20250514")
|
|
55
|
+
cwd: Working directory for the agent
|
|
56
|
+
emitter: Event emitter for streaming events to UI
|
|
57
|
+
system_prompt: Custom system prompt
|
|
58
|
+
plan_mode: If True, restrict to read-only tools
|
|
59
|
+
"""
|
|
60
|
+
self.model = model
|
|
61
|
+
self.cwd = cwd or str(Path.cwd())
|
|
62
|
+
self.emitter = emitter
|
|
63
|
+
self.system_prompt = system_prompt
|
|
64
|
+
self.plan_mode = plan_mode
|
|
65
|
+
self._emdash_server = None # Lazy init
|
|
66
|
+
|
|
67
|
+
def _get_emdash_mcp_server(self):
|
|
68
|
+
"""Get or create in-process MCP server for emdash-specific tools."""
|
|
69
|
+
if self._emdash_server is not None:
|
|
70
|
+
return self._emdash_server
|
|
71
|
+
|
|
72
|
+
from claude_agent_sdk import tool, create_sdk_mcp_server
|
|
73
|
+
|
|
74
|
+
@tool(
|
|
75
|
+
"semantic_search",
|
|
76
|
+
"Search code using natural language. Returns relevant functions, classes, and files.",
|
|
77
|
+
{"query": str, "limit": int, "entity_types": list}
|
|
78
|
+
)
|
|
79
|
+
async def semantic_search(args):
|
|
80
|
+
"""Run semantic search on the codebase."""
|
|
81
|
+
try:
|
|
82
|
+
from ...graph.connection import get_connection
|
|
83
|
+
from ..tools.search import SemanticSearchTool
|
|
84
|
+
|
|
85
|
+
conn = get_connection()
|
|
86
|
+
search_tool = SemanticSearchTool(conn)
|
|
87
|
+
result = search_tool.execute(
|
|
88
|
+
query=args["query"],
|
|
89
|
+
limit=args.get("limit", 10),
|
|
90
|
+
entity_types=args.get("entity_types"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if result.success:
|
|
94
|
+
# Format result for LLM
|
|
95
|
+
result_text = json.dumps(result.to_dict(), indent=2)
|
|
96
|
+
return {
|
|
97
|
+
"content": [
|
|
98
|
+
{"type": "text", "text": result_text}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
else:
|
|
102
|
+
return {
|
|
103
|
+
"content": [
|
|
104
|
+
{"type": "text", "text": f"Search failed: {result.error}"}
|
|
105
|
+
],
|
|
106
|
+
"isError": True,
|
|
107
|
+
}
|
|
108
|
+
except Exception as e:
|
|
109
|
+
log.exception("Semantic search failed")
|
|
110
|
+
return {
|
|
111
|
+
"content": [
|
|
112
|
+
{"type": "text", "text": f"Search error: {str(e)}"}
|
|
113
|
+
],
|
|
114
|
+
"isError": True,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
self._emdash_server = create_sdk_mcp_server(
|
|
118
|
+
name="emdash",
|
|
119
|
+
version="1.0.0",
|
|
120
|
+
tools=[semantic_search]
|
|
121
|
+
)
|
|
122
|
+
return self._emdash_server
|
|
123
|
+
|
|
124
|
+
def _get_options(self):
|
|
125
|
+
"""Build SDK options with native features."""
|
|
126
|
+
from claude_agent_sdk import ClaudeAgentOptions
|
|
127
|
+
|
|
128
|
+
# Base allowed tools - SDK built-ins
|
|
129
|
+
allowed_tools = [
|
|
130
|
+
# Read-only tools (always available)
|
|
131
|
+
"Read", "Glob", "Grep", "Skill",
|
|
132
|
+
# emdash custom tools
|
|
133
|
+
"mcp__emdash__semantic_search",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# Add write tools unless in plan mode
|
|
137
|
+
if not self.plan_mode:
|
|
138
|
+
allowed_tools.extend([
|
|
139
|
+
"Write", "Bash",
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
# Build MCP servers config
|
|
143
|
+
mcp_servers = {
|
|
144
|
+
"emdash": self._get_emdash_mcp_server(), # In-process (semantic search)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Add graph MCP if available
|
|
148
|
+
graph_mcp_path = Path(self.cwd) / ".emdash" / "mcp.json"
|
|
149
|
+
if graph_mcp_path.exists():
|
|
150
|
+
# External graph MCP server for code analysis
|
|
151
|
+
mcp_servers["graph"] = {
|
|
152
|
+
"type": "stdio",
|
|
153
|
+
"command": "python",
|
|
154
|
+
"args": ["-m", "emdash_graph_mcp.server"],
|
|
155
|
+
"env": {"REPO_ROOT": self.cwd},
|
|
156
|
+
}
|
|
157
|
+
allowed_tools.extend([
|
|
158
|
+
"mcp__graph__expand_node",
|
|
159
|
+
"mcp__graph__get_callers",
|
|
160
|
+
"mcp__graph__get_callees",
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
return ClaudeAgentOptions(
|
|
164
|
+
model=self.model,
|
|
165
|
+
cwd=self.cwd,
|
|
166
|
+
system_prompt=self.system_prompt,
|
|
167
|
+
|
|
168
|
+
# Native Skills support - load from .claude/skills/
|
|
169
|
+
setting_sources=["user", "project"],
|
|
170
|
+
|
|
171
|
+
# MCP servers
|
|
172
|
+
mcp_servers=mcp_servers,
|
|
173
|
+
|
|
174
|
+
# Allowed tools
|
|
175
|
+
allowed_tools=allowed_tools,
|
|
176
|
+
|
|
177
|
+
# Permission mode
|
|
178
|
+
permission_mode="acceptEdits" if not self.plan_mode else "plan",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _emit(self, event_type, data: dict):
|
|
182
|
+
"""Emit event to emitter if available."""
|
|
183
|
+
if self.emitter:
|
|
184
|
+
from ..events import EventType
|
|
185
|
+
self.emitter.emit(getattr(EventType, event_type), data)
|
|
186
|
+
|
|
187
|
+
async def run(self, prompt: str) -> AsyncIterator[dict]:
|
|
188
|
+
"""Execute agent with SDK.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
prompt: User prompt/task
|
|
192
|
+
|
|
193
|
+
Yields:
|
|
194
|
+
Event dicts for UI streaming
|
|
195
|
+
"""
|
|
196
|
+
from claude_agent_sdk import (
|
|
197
|
+
ClaudeSDKClient,
|
|
198
|
+
AssistantMessage,
|
|
199
|
+
TextBlock,
|
|
200
|
+
ToolUseBlock,
|
|
201
|
+
ToolResultBlock,
|
|
202
|
+
ResultMessage,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
options = self._get_options()
|
|
206
|
+
|
|
207
|
+
# Emit session start
|
|
208
|
+
self._emit("SESSION_START", {
|
|
209
|
+
"model": self.model,
|
|
210
|
+
"agent_name": "Emdash Code (SDK)",
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
215
|
+
await client.query(prompt)
|
|
216
|
+
|
|
217
|
+
async for message in client.receive_response():
|
|
218
|
+
# Process message and yield events
|
|
219
|
+
if isinstance(message, AssistantMessage):
|
|
220
|
+
for block in message.content:
|
|
221
|
+
if isinstance(block, TextBlock):
|
|
222
|
+
self._emit("PARTIAL_RESPONSE", {"text": block.text})
|
|
223
|
+
yield {"type": "text", "content": block.text}
|
|
224
|
+
|
|
225
|
+
elif isinstance(block, ToolUseBlock):
|
|
226
|
+
self._emit("TOOL_START", {
|
|
227
|
+
"tool": block.name,
|
|
228
|
+
"input": block.input,
|
|
229
|
+
})
|
|
230
|
+
yield {
|
|
231
|
+
"type": "tool_use",
|
|
232
|
+
"name": block.name,
|
|
233
|
+
"input": block.input,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
elif isinstance(block, ToolResultBlock):
|
|
237
|
+
self._emit("TOOL_RESULT", {
|
|
238
|
+
"tool": block.tool_use_id,
|
|
239
|
+
"result": str(block.content)[:500],
|
|
240
|
+
})
|
|
241
|
+
yield {
|
|
242
|
+
"type": "tool_result",
|
|
243
|
+
"tool_use_id": block.tool_use_id,
|
|
244
|
+
"content": block.content,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
elif isinstance(message, ResultMessage):
|
|
248
|
+
yield {
|
|
249
|
+
"type": "result",
|
|
250
|
+
"duration_ms": getattr(message, "duration_ms", None),
|
|
251
|
+
"cost_usd": getattr(message, "total_cost_usd", None),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
log.exception("SDK agent execution failed")
|
|
256
|
+
self._emit("ERROR", {"error": str(e)})
|
|
257
|
+
yield {"type": "error", "error": str(e)}
|
|
258
|
+
|
|
259
|
+
finally:
|
|
260
|
+
self._emit("SESSION_END", {})
|
|
261
|
+
|
|
262
|
+
async def chat(self, message: str) -> str:
|
|
263
|
+
"""Simple chat method for compatibility.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
message: User message
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Assistant response text
|
|
270
|
+
"""
|
|
271
|
+
response_text = ""
|
|
272
|
+
async for event in self.run(message):
|
|
273
|
+
if event.get("type") == "text":
|
|
274
|
+
response_text += event.get("content", "")
|
|
275
|
+
return response_text
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def is_claude_model(model: str) -> bool:
|
|
279
|
+
"""Check if a model string refers to a Claude model.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
model: Model string (e.g., "claude-sonnet-4", "haiku", "fireworks:...")
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
True if this is a Claude model that can use the SDK
|
|
286
|
+
"""
|
|
287
|
+
model_lower = model.lower()
|
|
288
|
+
|
|
289
|
+
# Check for non-Claude providers first (more specific)
|
|
290
|
+
non_claude_patterns = [
|
|
291
|
+
"fireworks:",
|
|
292
|
+
"openai:",
|
|
293
|
+
"gpt-",
|
|
294
|
+
"o1",
|
|
295
|
+
"o3",
|
|
296
|
+
"o4",
|
|
297
|
+
"accounts/fireworks",
|
|
298
|
+
"minimax",
|
|
299
|
+
"glm",
|
|
300
|
+
"gemini",
|
|
301
|
+
"llama",
|
|
302
|
+
"mistral",
|
|
303
|
+
"qwen",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
for pattern in non_claude_patterns:
|
|
307
|
+
if pattern in model_lower:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# Check for Claude indicators
|
|
311
|
+
claude_indicators = [
|
|
312
|
+
"claude",
|
|
313
|
+
"anthropic",
|
|
314
|
+
"haiku",
|
|
315
|
+
"sonnet",
|
|
316
|
+
"opus",
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
for indicator in claude_indicators:
|
|
320
|
+
if indicator in model_lower:
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
# Default: assume not Claude (safer)
|
|
324
|
+
return False
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Utility classes and functions for the agent runner."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, date
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SafeJSONEncoder(json.JSONEncoder):
|
|
9
|
+
"""JSON encoder that handles Neo4j types and other non-serializable objects."""
|
|
10
|
+
|
|
11
|
+
def default(self, obj: Any) -> Any:
|
|
12
|
+
# Handle datetime objects
|
|
13
|
+
if isinstance(obj, (datetime, date)):
|
|
14
|
+
return obj.isoformat()
|
|
15
|
+
|
|
16
|
+
# Handle Neo4j DateTime
|
|
17
|
+
if hasattr(obj, 'isoformat'):
|
|
18
|
+
return obj.isoformat()
|
|
19
|
+
|
|
20
|
+
# Handle Neo4j Date, Time, etc.
|
|
21
|
+
if hasattr(obj, 'to_native'):
|
|
22
|
+
return str(obj.to_native())
|
|
23
|
+
|
|
24
|
+
# Handle sets
|
|
25
|
+
if isinstance(obj, set):
|
|
26
|
+
return list(obj)
|
|
27
|
+
|
|
28
|
+
# Handle bytes
|
|
29
|
+
if isinstance(obj, bytes):
|
|
30
|
+
return obj.decode('utf-8', errors='replace')
|
|
31
|
+
|
|
32
|
+
# Fallback to string representation
|
|
33
|
+
try:
|
|
34
|
+
return str(obj)
|
|
35
|
+
except Exception:
|
|
36
|
+
return f"<non-serializable: {type(obj).__name__}>"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def summarize_tool_result(result: Any) -> str:
|
|
40
|
+
"""Create a brief summary of a tool result.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
result: ToolResult object with success, error, and data attributes.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Brief summary string.
|
|
47
|
+
"""
|
|
48
|
+
if not result.success:
|
|
49
|
+
return f"Error: {result.error}"
|
|
50
|
+
|
|
51
|
+
if not result.data:
|
|
52
|
+
return "Empty result"
|
|
53
|
+
|
|
54
|
+
data = result.data
|
|
55
|
+
|
|
56
|
+
if "results" in data:
|
|
57
|
+
return f"{len(data['results'])} results"
|
|
58
|
+
elif "root_node" in data:
|
|
59
|
+
node = data["root_node"]
|
|
60
|
+
name = node.get("qualified_name") or node.get("file_path", "unknown")
|
|
61
|
+
return f"Expanded: {name}"
|
|
62
|
+
elif "callers" in data:
|
|
63
|
+
return f"{len(data['callers'])} callers"
|
|
64
|
+
elif "callees" in data:
|
|
65
|
+
return f"{len(data['callees'])} callees"
|
|
66
|
+
|
|
67
|
+
return "Completed"
|