first commit
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Main entry point for the tools module."""
|
||||
|
||||
# Import all split tool modules.
|
||||
from .common import print_tool_output
|
||||
|
||||
|
||||
from .command_tools import (
|
||||
execute_command,
|
||||
)
|
||||
|
||||
# Requirement refinement tools.
|
||||
from .requirements_tools import (
|
||||
make_user_query_more_detailed,
|
||||
)
|
||||
|
||||
# Code generation and execution tools.
|
||||
from .code_tools import (
|
||||
cad_code_generator,
|
||||
)
|
||||
|
||||
# SketchPad operation tools.
|
||||
from .sketch_tools import (
|
||||
sketch_pad_operations,
|
||||
)
|
||||
|
||||
# Model multi-view rendering tools.
|
||||
from .model_view_tools import get_visual_feedback
|
||||
|
||||
from .builtin_file_toolkit import create_builtin_file_tools
|
||||
|
||||
|
||||
# Export all tool functions to maintain backward compatibility.
|
||||
__all__ = [
|
||||
"make_user_query_more_detailed",
|
||||
"cad_code_generator",
|
||||
"execute_command",
|
||||
"sketch_pad_operations",
|
||||
"get_visual_feedback",
|
||||
"print_tool_output",
|
||||
"create_builtin_file_tools",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from SimpleLLMFunc.builtin import FileToolset
|
||||
from SimpleLLMFunc.tool import Tool
|
||||
|
||||
|
||||
def create_builtin_file_tools(workspace: Optional[str | Path] = None) -> List[Tool]:
|
||||
"""Create SimpleLLMFunc builtin file tools scoped to the active workspace."""
|
||||
|
||||
root = Path(workspace).expanduser().resolve() if workspace else Path.cwd().resolve()
|
||||
return FileToolset(root).toolset
|
||||
|
||||
|
||||
__all__ = ["create_builtin_file_tools"]
|
||||
@@ -0,0 +1,274 @@
|
||||
"""CAD code generation tool implemented as a specialist subagent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any, Optional
|
||||
|
||||
from SimpleLLMFunc import llm_chat, tool
|
||||
from SimpleLLMFunc.type import HistoryList
|
||||
|
||||
from .builtin_file_toolkit import create_builtin_file_tools
|
||||
from .command_tools import execute_command
|
||||
from .common import (
|
||||
SUBAGENT_MAX_TOOL_CALLS,
|
||||
build_simplecad_workspace_fact_block,
|
||||
get_config,
|
||||
print_tool_output,
|
||||
)
|
||||
from .sketch_tools import sketch_pad_operations
|
||||
from .subagent_utils import run_subagent_with_events
|
||||
|
||||
|
||||
def create_codegen_subagent_tools(
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> list[Any]:
|
||||
"""Create the low-level tools owned by the CAD codegen specialist."""
|
||||
|
||||
return [
|
||||
execute_command,
|
||||
sketch_pad_operations,
|
||||
*create_builtin_file_tools(workspace),
|
||||
]
|
||||
|
||||
|
||||
def _build_codegen_request(
|
||||
*,
|
||||
task: str,
|
||||
target_file_path: str,
|
||||
validation_command: Optional[str],
|
||||
requirement_sketch_key: Optional[str] = None,
|
||||
) -> str:
|
||||
parts = [f"target_file: {target_file_path}"]
|
||||
if validation_command and validation_command.strip():
|
||||
parts.append(f"validation_command: {validation_command.strip()}")
|
||||
if requirement_sketch_key and requirement_sketch_key.strip():
|
||||
parts.append(f"requirement_key: {requirement_sketch_key.strip()}")
|
||||
parts.append("")
|
||||
parts.append(build_simplecad_workspace_fact_block())
|
||||
parts.append("")
|
||||
parts.append(task.strip())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_python_code_block(text: str) -> Optional[str]:
|
||||
match = re.search(r"```python\s*(.*?)```", text, flags=re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _build_missing_code_retry_request(
|
||||
*,
|
||||
target_file_path: str,
|
||||
) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<RETRY_AFTER_NO_CODE>",
|
||||
f"You ended without writing any script to {target_file_path}.",
|
||||
f"You must write the required Python script directly to {target_file_path} before you finish.",
|
||||
"Do not stop after planning, describing the approach, or pasting a code block in chat.",
|
||||
"Use the file-writing tool now, save the script to disk, then validate/debug until export succeeds.",
|
||||
"</RETRY_AFTER_NO_CODE>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _build_missing_code_retry_history(
|
||||
*,
|
||||
original_request: str,
|
||||
prior_report: str,
|
||||
) -> HistoryList:
|
||||
history: HistoryList = [{"role": "user", "content": original_request}]
|
||||
if prior_report.strip():
|
||||
history.append({"role": "assistant", "content": prior_report.strip()})
|
||||
return history
|
||||
|
||||
|
||||
def _default_validation_command(
|
||||
target_file_path: str, validation_command: Optional[str]
|
||||
) -> Optional[str]:
|
||||
if validation_command and validation_command.strip():
|
||||
return validation_command.strip()
|
||||
|
||||
if target_file_path.endswith(".py"):
|
||||
script_path = shlex.quote(target_file_path)
|
||||
output_dir = shlex.quote(str(Path(target_file_path).parent or Path(".")))
|
||||
stl_glob = f"{output_dir}/*.stl"
|
||||
step_glob = f"{output_dir}/*.step"
|
||||
stp_glob = f"{output_dir}/*.stp"
|
||||
return (
|
||||
f"uv run python {script_path} && "
|
||||
f"ls {stl_glob} && "
|
||||
f"(ls {step_glob} || ls {stp_glob})"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_latest_code(target_file_path: str) -> Optional[str]:
|
||||
candidate = Path(target_file_path)
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path.cwd() / candidate
|
||||
|
||||
if not candidate.exists() or not candidate.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
return candidate.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="cad_code_generator",
|
||||
description=(
|
||||
"Delegate CAD script creation or repair to a specialist subagent that owns "
|
||||
"builtin file tools and can iteratively debug the target file."
|
||||
),
|
||||
best_practices=[
|
||||
"MUST pass requirement_sketch_key (the req_xxxx from make_user_query_more_detailed) so the specialist retrieves the detailed spec.",
|
||||
"Use one complete natural-language task block instead of splitting context across many parameters.",
|
||||
"In the task, always say whether this is create-new-file or modify-existing-code.",
|
||||
"In the task, include the full user intent, failure context, relevant SketchPad ids, and success criteria.",
|
||||
"In the task, explicitly tell the specialist to validate and keep debugging until STL and STEP/STP export succeeds.",
|
||||
"Always provide the exact target_file_path for model.py or the file to repair.",
|
||||
],
|
||||
)
|
||||
async def cad_code_generator(
|
||||
task: str,
|
||||
target_file_path: str,
|
||||
requirement_sketch_key: Optional[str] = None,
|
||||
event_emitter: Any = None,
|
||||
) -> str:
|
||||
"""Run the CAD coding specialist as a single-call subagent.
|
||||
|
||||
Args:
|
||||
task: A complete natural-language mission for the coding specialist.
|
||||
This should explicitly include:
|
||||
- the full user intent,
|
||||
- whether the job is create-new-file or modify-existing-code,
|
||||
- the concrete modification target or creation goal,
|
||||
- any traceback / visual feedback / failure context,
|
||||
- any relevant SketchPad ids that the specialist should inspect,
|
||||
- any reference-code SketchPad ids if they matter,
|
||||
- the expected success criteria,
|
||||
- and an explicit instruction to validate and keep debugging until model export succeeds.
|
||||
Prefer one complete instruction block instead of splitting context across
|
||||
many parameters.
|
||||
target_file_path: The exact path of the script file that the specialist owns.
|
||||
In the normal workflow this should be the final `model.py` path.
|
||||
requirement_sketch_key: REQUIRED. The SketchPad key (e.g. req_xxxx) from make_user_query_more_detailed.
|
||||
The specialist will retrieve and follow this detailed requirement.
|
||||
event_emitter: Optional tool event emitter used to forward nested specialist
|
||||
progress events back to the outer agent event stream.
|
||||
|
||||
Returns:
|
||||
A concise report from the specialist plus the latest code snapshot.
|
||||
"""
|
||||
|
||||
actual_validation_command = _default_validation_command(
|
||||
target_file_path,
|
||||
None,
|
||||
)
|
||||
|
||||
request_payload = _build_codegen_request(
|
||||
task=task.strip(),
|
||||
target_file_path=target_file_path,
|
||||
validation_command=actual_validation_command,
|
||||
requirement_sketch_key=requirement_sketch_key,
|
||||
)
|
||||
|
||||
print_tool_output(
|
||||
"🧠 CAD Code Specialist",
|
||||
"\n".join(
|
||||
[
|
||||
f"Target file: {target_file_path}",
|
||||
f"Validation command: {actual_validation_command or '(not provided)'}",
|
||||
f"Task summary: {task.strip()[:160]}",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
report = await run_subagent_with_events(
|
||||
specialist_callable=cad_code_generator_specialist,
|
||||
specialist_kwargs={
|
||||
"message": request_payload,
|
||||
"history": [],
|
||||
},
|
||||
subagent_label="CAD Code Specialist",
|
||||
event_emitter=event_emitter,
|
||||
status_payload={
|
||||
"target_file_path": target_file_path,
|
||||
"validation_command": actual_validation_command,
|
||||
},
|
||||
)
|
||||
|
||||
written_code = _read_latest_code(target_file_path)
|
||||
latest_code = written_code or _extract_python_code_block(report)
|
||||
if written_code is None:
|
||||
print_tool_output(
|
||||
"⚠️ CAD Code Specialist",
|
||||
"First attempt did not write the target file. Appending a stricter follow-up instruction.",
|
||||
)
|
||||
retry_report = await run_subagent_with_events(
|
||||
specialist_callable=cad_code_generator_specialist,
|
||||
specialist_kwargs={
|
||||
"message": _build_missing_code_retry_request(
|
||||
target_file_path=target_file_path,
|
||||
),
|
||||
"history": _build_missing_code_retry_history(
|
||||
original_request=request_payload,
|
||||
prior_report=report,
|
||||
),
|
||||
},
|
||||
subagent_label="CAD Code Specialist",
|
||||
event_emitter=event_emitter,
|
||||
status_payload={
|
||||
"target_file_path": target_file_path,
|
||||
"validation_command": actual_validation_command,
|
||||
"retry_reason": "no_code_written",
|
||||
},
|
||||
)
|
||||
report = retry_report.strip() or report
|
||||
written_code = _read_latest_code(target_file_path)
|
||||
latest_code = written_code or _extract_python_code_block(report)
|
||||
|
||||
if latest_code is None:
|
||||
return report.strip()
|
||||
|
||||
return (
|
||||
f"{report.strip()}\n\n"
|
||||
f"📁 Target file: {target_file_path}\n"
|
||||
f"📄 Latest code:\n```python\n{latest_code.strip()}\n```"
|
||||
)
|
||||
|
||||
|
||||
@llm_chat(
|
||||
llm_interface=get_config().REASONING_INTERFACE,
|
||||
toolkit=create_codegen_subagent_tools(),
|
||||
max_tool_calls=SUBAGENT_MAX_TOOL_CALLS,
|
||||
stream=True,
|
||||
enable_event=True,
|
||||
timeout=900,
|
||||
temperature=0.8,
|
||||
)
|
||||
async def cad_code_generator_specialist(
|
||||
message: str,
|
||||
history: HistoryList | None = None,
|
||||
) -> None: # type: ignore[misc]
|
||||
"""You are a CAD coding agent. Write Python code directly to the target file with echo_into.
|
||||
|
||||
Always create/write the target file first. Use the workspace facts included in the user message. Read the chosen skill root's `SKILL.md`, then `references/docs/api/README.md`, then the exact API Markdown pages you use. Use the provided `validation_command` exactly; when you run Python in this repo/workspace, prefer `uv run python ...`. Run validation directly with `execute_command`; that tool already allows up to 600 seconds for a command, so use it as the standard execution path. After a successful script run, verify exported files with `ls` instead of rerunning the same script just to check whether STL/STEP outputs exist. Do not print whole solids, assemblies, or full model objects for inspection; use QL queries and print only the small queried facts you need for grounding/debugging. Keep debugging until the script is executed successfully and exports both STL and STEP/STP (for example, ./model.stl and ./model.step).
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"cad_code_generator",
|
||||
"cad_code_generator_specialist",
|
||||
"create_codegen_subagent_tools",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
|
||||
from SimpleLLMFunc import tool
|
||||
from .common import print_tool_output
|
||||
|
||||
|
||||
EXECUTE_COMMAND_TIMEOUT_SECONDS = 600
|
||||
|
||||
|
||||
def _build_command_failure_message(result) -> str:
|
||||
parts = [f"Command failed with exit code {result.returncode}."]
|
||||
|
||||
stdout = result.stdout.strip()
|
||||
stderr = result.stderr.strip()
|
||||
|
||||
if stdout:
|
||||
parts.append(f"STDOUT:\n{stdout}")
|
||||
if stderr:
|
||||
parts.append(f"STDERR:\n{stderr}")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _build_command_timeout_message(exc) -> str:
|
||||
parts = [
|
||||
f"Command timed out after {EXECUTE_COMMAND_TIMEOUT_SECONDS} seconds.",
|
||||
"The process may be stuck, waiting for input, or simply taking too long.",
|
||||
]
|
||||
|
||||
stdout = (exc.stdout or "").strip()
|
||||
stderr = (exc.stderr or "").strip()
|
||||
|
||||
if stdout:
|
||||
parts.append(f"Partial STDOUT:\n{stdout}")
|
||||
if stderr:
|
||||
parts.append(f"Partial STDERR:\n{stderr}")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
@tool(
|
||||
name="execute_command",
|
||||
description="Execute a system command in shell and return the output.",
|
||||
)
|
||||
async def execute_command(command: str) -> str:
|
||||
"""Execute a system command in shell and return the output.
|
||||
|
||||
Args:
|
||||
command: The system command to execute, recommended commands are uv run python <script path>
|
||||
Returns:
|
||||
The command output (stdout on success, stderr on failure)
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
try:
|
||||
print_tool_output("⚡ Running Command", f"Executing: {command}")
|
||||
|
||||
start_time = time.time()
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=EXECUTE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
if result.returncode == 0:
|
||||
print_tool_output(
|
||||
"✅ Command Completed",
|
||||
f"Return code: {result.returncode}, Time: {execution_time:.2f}s, Output: {len(result.stdout)} chars",
|
||||
)
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
print_tool_output(
|
||||
"❌ Command Failed",
|
||||
f"Command failed.\nError: {result.stderr.strip()}",
|
||||
)
|
||||
|
||||
return _build_command_failure_message(result)
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
print_tool_output(
|
||||
"⏱️ Command Timed Out", f"Timeout while executing command: {str(exc)}"
|
||||
)
|
||||
return _build_command_timeout_message(exc)
|
||||
|
||||
except Exception as e:
|
||||
print_tool_output("💥 Command Error", f"Command execution failed: {str(e)}")
|
||||
return f"Command execution failed: {str(e)}"
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Common tool functions and configuration.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from config.config import get_config
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
|
||||
config = get_config()
|
||||
SUBAGENT_MAX_TOOL_CALLS = 100
|
||||
_SKILL_RELATIVE_ROOTS = [
|
||||
Path("skills/simplecad-self-evolve"),
|
||||
Path("workspace/skills/simplecad-self-evolve"),
|
||||
]
|
||||
|
||||
|
||||
def print_tool_output(title: str, content: str, style: str = "cyan"):
|
||||
"""Simplified tool output function using plain print and separator lines."""
|
||||
print("\n>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>")
|
||||
print(f"{title}")
|
||||
print(content)
|
||||
print("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
|
||||
|
||||
def safe_asyncio_run(coro_func, *args, **kwargs):
|
||||
"""Helper function for safely running an async function with passed-in arguments."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, coro_func(*args, **kwargs))
|
||||
return future.result(timeout=30)
|
||||
else:
|
||||
return loop.run_until_complete(coro_func(*args, **kwargs))
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro_func(*args, **kwargs))
|
||||
|
||||
|
||||
def build_simplecad_workspace_fact_block() -> str:
|
||||
"""Return explicit working-directory facts for SimpleCAD subagents."""
|
||||
|
||||
cwd = Path.cwd().resolve()
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
|
||||
discovered_skill_roots: list[Path] = []
|
||||
candidate_bases = [cwd, repo_root, *cwd.parents]
|
||||
seen_candidates: set[Path] = set()
|
||||
|
||||
for base in candidate_bases:
|
||||
for relative_root in _SKILL_RELATIVE_ROOTS:
|
||||
candidate = (base / relative_root).resolve()
|
||||
if candidate in seen_candidates:
|
||||
continue
|
||||
seen_candidates.add(candidate)
|
||||
if candidate.is_dir():
|
||||
discovered_skill_roots.append(candidate)
|
||||
|
||||
preferred_skill_root = discovered_skill_roots[0] if discovered_skill_roots else None
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
try:
|
||||
relative = path.relative_to(cwd).as_posix()
|
||||
suffix = "/" if path.is_dir() else ""
|
||||
return f"./{relative}{suffix}"
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
lines = [
|
||||
"[Workspace Facts]",
|
||||
f"Current working directory: {cwd}",
|
||||
f"Repository root: {repo_root}",
|
||||
"Use relative paths from this directory.",
|
||||
"Skill root: use the preferred skill root below.",
|
||||
"Skill layout: <skill_root>/SKILL.md, <skill_root>/references/docs/api/README.md, <skill_root>/references/docs/api/*.md, <skill_root>/references/docs/core/*.md, <skill_root>/scripts/, <skill_root>/cases/",
|
||||
]
|
||||
|
||||
if preferred_skill_root is not None:
|
||||
lines.extend(
|
||||
[
|
||||
f"Preferred skill root: {_display_path(preferred_skill_root)}",
|
||||
"You MUST read these files before choosing APIs:",
|
||||
f"- {_display_path(preferred_skill_root / 'SKILL.md')}",
|
||||
f"- {_display_path(preferred_skill_root / 'references/docs/api/README.md')}",
|
||||
]
|
||||
)
|
||||
|
||||
if discovered_skill_roots:
|
||||
lines.append("Detected skill roots:")
|
||||
for skill_root in discovered_skill_roots:
|
||||
lines.append(f"- {_display_path(skill_root)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from context.conversation_manager import get_current_context, get_current_sketch_pad
|
||||
|
||||
|
||||
def _content_item_type(item: Any) -> Optional[str]:
|
||||
if isinstance(item, dict):
|
||||
value = item.get("type")
|
||||
return value if isinstance(value, str) else None
|
||||
value = getattr(item, "type", None)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _content_item_image_payload(item: Any) -> Any:
|
||||
if isinstance(item, dict):
|
||||
return item.get("image_url")
|
||||
return getattr(item, "image_url", None)
|
||||
|
||||
|
||||
def _image_payload_local_path(image_payload: Any) -> Optional[str]:
|
||||
if isinstance(image_payload, dict):
|
||||
value = image_payload.get("local_path")
|
||||
return value if isinstance(value, str) else None
|
||||
value = getattr(image_payload, "local_path", None)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _normalize_existing_file_path(path_value: Any) -> Optional[str]:
|
||||
if not isinstance(path_value, (str, Path)):
|
||||
return None
|
||||
|
||||
raw_value = str(path_value).strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
|
||||
candidate = Path(raw_value).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = (Path.cwd() / candidate).resolve()
|
||||
|
||||
try:
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return str(candidate)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_sketch_pad_image_path(reference: str) -> Optional[str]:
|
||||
if not reference.startswith("key:"):
|
||||
return None
|
||||
|
||||
sketch_pad = get_current_sketch_pad()
|
||||
if sketch_pad is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = sketch_pad.get_value(reference[4:])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
direct_path = _normalize_existing_file_path(value)
|
||||
if direct_path is not None:
|
||||
return direct_path
|
||||
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
for key in ("local_path", "path", "image_path", "query_image_path"):
|
||||
resolved_path = _normalize_existing_file_path(value.get(key))
|
||||
if resolved_path is not None:
|
||||
return resolved_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_uploaded_reference_image_path() -> Optional[str]:
|
||||
context = get_current_context()
|
||||
if context is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
messages = context.retrieve_full_messages()
|
||||
except Exception:
|
||||
try:
|
||||
messages = context.retrieve_messages()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for message in reversed(messages):
|
||||
if getattr(message, "role", None) != "user":
|
||||
continue
|
||||
|
||||
content = getattr(message, "content", None)
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for item in reversed(content):
|
||||
if _content_item_type(item) != "image_url":
|
||||
continue
|
||||
|
||||
image_payload = _content_item_image_payload(item)
|
||||
resolved_path = _normalize_existing_file_path(
|
||||
_image_payload_local_path(image_payload)
|
||||
)
|
||||
if resolved_path is not None:
|
||||
return resolved_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_reference_image_path(query_image_path: Optional[str]) -> Optional[str]:
|
||||
if isinstance(query_image_path, str) and query_image_path.strip():
|
||||
stripped_path = query_image_path.strip()
|
||||
|
||||
sketch_pad_path = _resolve_sketch_pad_image_path(stripped_path)
|
||||
if sketch_pad_path is not None:
|
||||
return sketch_pad_path
|
||||
|
||||
direct_path = _normalize_existing_file_path(stripped_path)
|
||||
if direct_path is not None:
|
||||
return direct_path
|
||||
|
||||
return get_latest_uploaded_reference_image_path()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Requirement refinement tool implemented as a specialist subagent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from SimpleLLMFunc import llm_chat, tool
|
||||
from SimpleLLMFunc.type import HistoryList
|
||||
|
||||
from context.conversation_manager import get_current_sketch_pad
|
||||
from .builtin_file_toolkit import create_builtin_file_tools
|
||||
from .command_tools import execute_command
|
||||
from .common import (
|
||||
SUBAGENT_MAX_TOOL_CALLS,
|
||||
build_simplecad_workspace_fact_block,
|
||||
get_config,
|
||||
print_tool_output,
|
||||
)
|
||||
from .reference_image import resolve_reference_image_path
|
||||
from .sketch_tools import sketch_pad_operations
|
||||
from .subagent_utils import run_subagent_with_events
|
||||
|
||||
|
||||
def create_requirement_refinement_subagent_tools(
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> list[Any]:
|
||||
"""Create the low-level tools owned by the requirement specialist.
|
||||
Specialist only produces formatted text; SketchPad storage is done by the caller.
|
||||
"""
|
||||
return [
|
||||
execute_command,
|
||||
sketch_pad_operations,
|
||||
*create_builtin_file_tools(workspace),
|
||||
]
|
||||
|
||||
|
||||
_REQUIRED_SECTIONS = [
|
||||
"## API Reference",
|
||||
"## Refined User Requirements",
|
||||
"## Parameter Table",
|
||||
"## Modeling Process",
|
||||
"## Notes",
|
||||
]
|
||||
|
||||
|
||||
def _normalize_requirement_output(text: str) -> str:
|
||||
"""Trim preamble and ensure all required sections exist."""
|
||||
t = text.strip()
|
||||
for h in _REQUIRED_SECTIONS:
|
||||
idx = t.find(h)
|
||||
if idx >= 0:
|
||||
t = t[idx:]
|
||||
break
|
||||
for h in _REQUIRED_SECTIONS:
|
||||
if h not in t:
|
||||
t += f"\n\n{h}\n"
|
||||
return t.strip()
|
||||
|
||||
|
||||
def _build_requirement_request(
|
||||
*,
|
||||
query: str,
|
||||
query_image_path: Optional[str],
|
||||
) -> str:
|
||||
parts = [
|
||||
"Generate a detailed modeling specification. Output must include: ## API Reference, ## Refined User Requirements, ## Parameter Table, ## Modeling Process, ## Notes.",
|
||||
"Use the workspace facts below.",
|
||||
"Before you write the final answer, you MUST use file tools to read the preferred skill root's `SKILL.md`, then `references/docs/api/README.md`, then the exact API Markdown pages you cite.",
|
||||
"Do not answer from memory. If you have not read those files yet, continue using tools.",
|
||||
"In `## API Reference`, cite the concrete file paths you read and only recommend APIs whose exact Markdown pages you actually opened.",
|
||||
"If the task mentions SketchPad keys, use `sketch_pad_operations` to retrieve them before refining the requirement.",
|
||||
"",
|
||||
build_simplecad_workspace_fact_block(),
|
||||
"",
|
||||
"[User Query]",
|
||||
query.strip(),
|
||||
]
|
||||
if query_image_path and query_image_path.strip():
|
||||
parts.append("\n[Reference image attached below]")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _image_path_to_base64_data_url(image_path: str) -> Optional[str]:
|
||||
"""Read image file and return data URL for OpenAI API."""
|
||||
p = Path(image_path)
|
||||
if not p.exists() or not p.is_file():
|
||||
return None
|
||||
ext = p.suffix.lower()
|
||||
mime_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
mime = mime_map.get(ext, "image/jpeg")
|
||||
try:
|
||||
b64 = base64.b64encode(p.read_bytes()).decode("utf-8")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_message_with_image(
|
||||
text: str,
|
||||
query_image_path: Optional[str],
|
||||
) -> Union[str, list[dict[str, Any]]]:
|
||||
"""Build message: text only, or text + image as OpenAI content array."""
|
||||
if not query_image_path or not query_image_path.strip():
|
||||
return text
|
||||
data_url = _image_path_to_base64_data_url(query_image_path.strip())
|
||||
if not data_url:
|
||||
raise RuntimeError(
|
||||
f"Failed to load reference image: {query_image_path.strip()}"
|
||||
)
|
||||
return [
|
||||
{"type": "text", "text": text},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
]
|
||||
|
||||
|
||||
@tool(
|
||||
name="make_user_query_more_detailed",
|
||||
description=(
|
||||
"Refine and expand the user's modeling requirement through a specialist subagent. "
|
||||
"The specialist can inspect local skill docs, inspect APIs, read local files, and consult SketchPad "
|
||||
"before producing a structured modeling specification."
|
||||
),
|
||||
best_practices=[
|
||||
"Pass the complete user request in `query`, not only a short delta fragment.",
|
||||
"If the user provided a reference image, pass its workspace-local path in `query_image_path`.",
|
||||
"If `query_image_path` is omitted, the tool will automatically reuse the latest uploaded image from the active conversation when available.",
|
||||
"Use this tool when the modeling request is vague, underspecified, or needs a step-by-step plan before coding.",
|
||||
"The specialist will read local skill docs directly with its file tools to ground the refinement.",
|
||||
"The final result should include a structured modeling process, not only rewritten prose.",
|
||||
],
|
||||
)
|
||||
async def make_user_query_more_detailed(
|
||||
query: str,
|
||||
query_image_path: Optional[str] = None,
|
||||
event_emitter: Any = None,
|
||||
) -> str:
|
||||
"""Refine the user's modeling request via a requirement specialist subagent.
|
||||
|
||||
The refined requirement is always stored in SketchPad for downstream tools to reference.
|
||||
|
||||
Args:
|
||||
query: The user's original request. This may also mention SketchPad ids that the
|
||||
specialist should inspect.
|
||||
query_image_path: Optional workspace-local reference image path, typically something
|
||||
like `./uploads/<conversation_id>/query_image_001.png`.
|
||||
event_emitter: Optional tool event emitter used to forward nested specialist activity.
|
||||
|
||||
Returns:
|
||||
str: Refined requirement text with SketchPad key for reference.
|
||||
"""
|
||||
|
||||
print_tool_output(
|
||||
title="Requirement Refinement Started",
|
||||
content=f"Request: {query}",
|
||||
)
|
||||
|
||||
requested_query_image_path = (
|
||||
query_image_path.strip()
|
||||
if isinstance(query_image_path, str) and query_image_path.strip()
|
||||
else None
|
||||
)
|
||||
resolved_query_image_path = resolve_reference_image_path(query_image_path)
|
||||
|
||||
if requested_query_image_path is not None and resolved_query_image_path is None:
|
||||
raise RuntimeError(f"Reference image not found: {requested_query_image_path}")
|
||||
|
||||
if resolved_query_image_path is not None:
|
||||
print_tool_output(
|
||||
title="Reference Image Attached",
|
||||
content=f"Using reference image: {resolved_query_image_path}",
|
||||
)
|
||||
|
||||
text_content = _build_requirement_request(
|
||||
query=query,
|
||||
query_image_path=resolved_query_image_path,
|
||||
)
|
||||
message = _build_message_with_image(text_content, resolved_query_image_path)
|
||||
|
||||
result_text = await run_subagent_with_events(
|
||||
specialist_callable=requirement_refinement_specialist,
|
||||
specialist_kwargs={
|
||||
"message": message,
|
||||
"history": [],
|
||||
},
|
||||
subagent_label="Requirement Refinement Specialist",
|
||||
event_emitter=event_emitter,
|
||||
status_payload={
|
||||
"query": query,
|
||||
"query_image_path": resolved_query_image_path,
|
||||
},
|
||||
response_transform=_normalize_requirement_output,
|
||||
)
|
||||
|
||||
final_text = result_text.strip()
|
||||
print_tool_output(title="Refined User Requirements", content=final_text)
|
||||
|
||||
sketch_pad = get_current_sketch_pad()
|
||||
if sketch_pad is None:
|
||||
raise RuntimeError(
|
||||
"The refined requirement must be written to SketchPad, but there is no active conversation context."
|
||||
)
|
||||
|
||||
import uuid
|
||||
|
||||
sketch_key = f"req_{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
await sketch_pad.set_item(
|
||||
key=sketch_key,
|
||||
value=final_text,
|
||||
ttl=None,
|
||||
summary=None,
|
||||
tags={"detailed_query", "requirements", "expanded"},
|
||||
)
|
||||
|
||||
print_tool_output(
|
||||
title="💾 Stored In SketchPad",
|
||||
content=f"Key: {sketch_key}\nThe refined requirement has been saved for downstream tools.",
|
||||
)
|
||||
|
||||
return (
|
||||
"Detailed requirements generated and stored in SketchPad:\n\n"
|
||||
f"🔑 SketchPad Key: {sketch_key}\n"
|
||||
"# Tags: detailed_query, requirements, expanded\n"
|
||||
f'💡 Tip: You can now reference key "{sketch_key}" in later tool calls, for example:\n'
|
||||
"- include it in the natural-language task for `cad_code_generator`\n"
|
||||
"- store it alongside other constraints or debugging notes in SketchPad\n"
|
||||
"- create the target folder first, then use `echo_into` to write a file if needed\n"
|
||||
)
|
||||
except Exception as exc:
|
||||
print_tool_output(
|
||||
"❌ SketchPad Store Failed", f"Failed to store in SketchPad: {exc}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"The refined requirement must be written to SketchPad, but storage failed: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
@llm_chat(
|
||||
llm_interface=get_config().MULTIMODALITY_INTERFACE,
|
||||
toolkit=create_requirement_refinement_subagent_tools(),
|
||||
max_tool_calls=SUBAGENT_MAX_TOOL_CALLS,
|
||||
stream=True,
|
||||
enable_event=True,
|
||||
timeout=600,
|
||||
temperature=1.0,
|
||||
)
|
||||
async def requirement_refinement_specialist(
|
||||
message: Union[str, list[dict[str, Any]]],
|
||||
history: HistoryList | None = None,
|
||||
) -> None: # type: ignore[misc]
|
||||
"""Generate a detailed modeling specification. Output: ## API Reference, ## Refined User Requirements, ## Parameter Table, ## Modeling Process, ## Notes.
|
||||
|
||||
Use the workspace facts included in the user message. Read `SKILL.md`, then the API index, then the exact API Markdown pages you cite.
|
||||
REQUIRED: The detailed query MUST use exactly correct API names and code snippets.
|
||||
Tools: execute_command, sketch_pad_operations, read_file, grep, sed, echo_into.
|
||||
You MUST read SKILL.md and the API index before choosing APIs. Retrieve SketchPad artifacts when task mentions keys.
|
||||
|
||||
<EXAMPLE>
|
||||
User: "Create a 7.62mm rifle cartridge model"
|
||||
|
||||
## Refined User Requirements
|
||||
1. **Target Object**: A standard 7.62mm caliber rifle cartridge (Full Metal Jacket type).
|
||||
2. **Components**: The model consists of four parts: the bullet tip (projectile), the cartridge case (neck, shoulder, body), the rim/extractor groove, and a primer base.
|
||||
3. **Dimensions**:
|
||||
- **Projectile**: Diameter 7.62mm, ogive shape with a rounded tip.
|
||||
- **Case Body**: Maximum diameter approx 11.3mm, total case length 51mm (based on 7.62x51mm NATO standard).
|
||||
- **Shoulder/Neck**: Tapered transition from body to 7.62mm neck.
|
||||
4. **Output**: A single combined solid representing the exterior geometry of the cartridge.
|
||||
|
||||
## Parameter Table
|
||||
| Parameter | Type | Default Value | Calculation Logic |
|
||||
|---|---|---|---|
|
||||
| bullet_dia | float | 7.62 | Nominal caliber |
|
||||
| case_body_dia | float | 11.3 | Max diameter of the case body |
|
||||
| total_length | float | 71.0 | Full cartridge length including projectile |
|
||||
| body_length | float | 38.0 | Length from base to shoulder |
|
||||
| shoulder_length | float | 3.5 | Length of the tapered shoulder |
|
||||
| neck_length | float | 8.0 | Length of the neck holding the bullet |
|
||||
| case_length | float | 51.0 | body_length + shoulder_length + neck_length |
|
||||
| rim_dia | float | 11.5 | Diameter of the base rim |
|
||||
|
||||
## Modeling Process
|
||||
1. **Create Case Main Body**
|
||||
- **Purpose**: Create the main cylindrical propellant chamber.
|
||||
- **API**: `make_cylinder_rsolid`
|
||||
- **Spatial Reasoning**: Cylinder radius `case_body_dia/2`, height `body_length`, base at (0,0,0).
|
||||
|
||||
2. **Create Shoulder and Neck**
|
||||
- **Purpose**: Model the tapered transition and casing neck.
|
||||
- **API**: `make_cone_rsolid`, `make_cylinder_rsolid`, `translate_shape`
|
||||
- **Spatial Reasoning**: Shoulder cone bottom radius `case_body_dia/2`, top `bullet_dia/2`, height `shoulder_length`, translate to Z=body_length. Neck cylinder radius `bullet_dia/2`, height `neck_length`, translate to Z=body_length+shoulder_length.
|
||||
|
||||
3. **Create Projectile**
|
||||
- **Purpose**: Form the aerodynamic tip.
|
||||
- **API**: `make_cone_rsolid`, `union_rsolidlist`
|
||||
- **Spatial Reasoning**: Cone base radius `bullet_dia/2`, height `total_length-case_length`, translate to Z=case_length.
|
||||
|
||||
4. **Add Extractor Groove and Rim**
|
||||
- **Purpose**: Model the base where extractor grips.
|
||||
- **API**: `make_cylinder_rsolid`, `cut_rsolidlist`
|
||||
- **Spatial Reasoning**: Rim cylinder radius `rim_dia/2`, height 1.5. Cut groove with smaller cylinder.
|
||||
|
||||
5. **Final Assembly**
|
||||
- **Purpose**: Combine into single manifold solid.
|
||||
- **API**: `union_rsolidlist`
|
||||
- **Spatial Reasoning**: Boolean union on body, shoulder, neck, projectile, rim.
|
||||
|
||||
## Notes
|
||||
Based on 7.62x51mm NATO standard. APIs must be verified against SKILL.md.
|
||||
</EXAMPLE>
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"make_user_query_more_detailed",
|
||||
"requirement_refinement_specialist",
|
||||
"create_requirement_refinement_subagent_tools",
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""SketchPad operation tools."""
|
||||
|
||||
from SimpleLLMFunc import tool
|
||||
from typing import Optional, Any, Tuple, List
|
||||
import uuid
|
||||
from .common import print_tool_output
|
||||
from context.conversation_manager import get_current_sketch_pad
|
||||
|
||||
|
||||
@tool(
|
||||
name="sketch_pad_operations",
|
||||
description="Store, retrieve, search and manage data in SketchPad. Supports key-value storage with automatic summarization.",
|
||||
)
|
||||
async def sketch_pad_operations(
|
||||
operation: str,
|
||||
key: Optional[str] = None,
|
||||
value: Optional[str] = None,
|
||||
tags: Optional[str] = None,
|
||||
search_query: Optional[str] = None,
|
||||
ttl: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Perform operations on SketchPad storage.
|
||||
|
||||
Args:
|
||||
operation: One of "store", "retrieve", "delete", "list", "search_tags", "search", "clear", "stats"
|
||||
key: Key for store/retrieve/delete operations
|
||||
value: Value to store (required for store operation)
|
||||
tags: Comma-separated tags for store operation, marking the item with specific labels
|
||||
search_query: Query for search operations
|
||||
ttl: Time to live in seconds (optional for store)
|
||||
|
||||
Returns:
|
||||
str: Result of the operation
|
||||
"""
|
||||
|
||||
sketch_pad = get_current_sketch_pad()
|
||||
|
||||
if sketch_pad is None:
|
||||
return "Error: No active conversation context. SketchPad operations must be called within a conversation context."
|
||||
|
||||
try:
|
||||
if operation == "store":
|
||||
if not value:
|
||||
return "Error: value is required for store operation"
|
||||
|
||||
# Parse tags
|
||||
tag_set = set()
|
||||
if tags:
|
||||
tag_set = set(tag.strip() for tag in tags.split(","))
|
||||
|
||||
# Generate a stable default key when none is provided.
|
||||
actual_key_input = key or f"item_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
actual_key = await sketch_pad.set_item(
|
||||
key=actual_key_input,
|
||||
value=value,
|
||||
ttl=ttl,
|
||||
summary=None,
|
||||
tags=tag_set,
|
||||
)
|
||||
|
||||
print_tool_output(
|
||||
title="✅ SketchPad Store Succeeded",
|
||||
content=f"Key: {actual_key}\nValue length: {len(str(value))} chars\nTags: {tags or 'None'}",
|
||||
)
|
||||
return f"Stored successfully with key: {actual_key}"
|
||||
|
||||
elif operation == "retrieve":
|
||||
if not key:
|
||||
return "Error: key is required for retrieve operation"
|
||||
|
||||
value = sketch_pad.get_value(key)
|
||||
if value is None:
|
||||
print_tool_output(
|
||||
"❌ SketchPad Retrieve Failed", f"Key '{key}' not found"
|
||||
)
|
||||
return f"Key '{key}' not found"
|
||||
|
||||
value_str = str(value)
|
||||
print_tool_output(
|
||||
title="✅ SketchPad Retrieve Succeeded",
|
||||
content=(
|
||||
f"Key: {key}\nValue: {value_str[:200]}..."
|
||||
if len(value_str) > 200
|
||||
else f"Key: {key}\nValue: {value_str}"
|
||||
),
|
||||
)
|
||||
return value_str
|
||||
|
||||
elif operation == "delete":
|
||||
if not key:
|
||||
return "Error: key is required for delete operation"
|
||||
|
||||
success = sketch_pad.delete(key)
|
||||
if success:
|
||||
print_tool_output(
|
||||
"✅ SketchPad Delete Succeeded", f"Key '{key}' deleted"
|
||||
)
|
||||
return f"Key '{key}' deleted successfully"
|
||||
else:
|
||||
print_tool_output(
|
||||
"❌ SketchPad Delete Failed", f"Key '{key}' not found"
|
||||
)
|
||||
return f"Key '{key}' not found"
|
||||
|
||||
elif operation == "list":
|
||||
items = sketch_pad.list_items(include_value=False)
|
||||
if not items:
|
||||
return "SketchPad is empty"
|
||||
|
||||
result = "SketchPad contents:\n"
|
||||
for list_item in items:
|
||||
summary = list_item.summary or "No summary"
|
||||
result += f"- {list_item.key}: {summary[:50]}...\n"
|
||||
|
||||
print_tool_output("📋 SketchPad Contents", result)
|
||||
return result
|
||||
|
||||
elif operation == "search_tags":
|
||||
if not search_query:
|
||||
return "Error: search_query is required for search_tags operation"
|
||||
|
||||
# Parse tag query
|
||||
tag_set = set(tag.strip() for tag in search_query.split(","))
|
||||
results_raw = sketch_pad.search_by_tags(tag_set)
|
||||
results_tags: List[Tuple[str, Any]] = list(results_raw)
|
||||
|
||||
if not results_tags:
|
||||
return f"No items found with tags: {search_query}"
|
||||
|
||||
result = f"Found {len(results_tags)} items with tags '{search_query}':\n"
|
||||
for key, tag_item in results_tags[:5]:
|
||||
summary = tag_item.summary or "No summary"
|
||||
result += f"- {key}: {summary[:50]}...\n"
|
||||
|
||||
print_tool_output("🔍 SketchPad Tag Search Results", result)
|
||||
return result
|
||||
|
||||
elif operation == "search":
|
||||
if not search_query:
|
||||
return "Error: search_query is required for search operation"
|
||||
|
||||
results_raw = sketch_pad.search_by_content(search_query)
|
||||
results_content: List[Tuple[str, Any]] = list(results_raw)
|
||||
|
||||
if not results_content:
|
||||
return f"No items found for query: {search_query}"
|
||||
|
||||
result = f"Found {len(results_content)} items for '{search_query}':\n"
|
||||
for key, content_item in results_content[:5]:
|
||||
summary = content_item.summary or "No summary"
|
||||
result += f"- {key}: {summary[:50]}...\n"
|
||||
|
||||
print_tool_output("🔍 SketchPad Content Search Results", result)
|
||||
return result
|
||||
|
||||
elif operation == "clear":
|
||||
sketch_pad.clear()
|
||||
print_tool_output("🗑️ SketchPad Cleared", "All items have been removed")
|
||||
return "SketchPad cleared successfully"
|
||||
|
||||
elif operation == "stats":
|
||||
stats = sketch_pad.get_statistics()
|
||||
result = "SketchPad Statistics:\n"
|
||||
result += f"- Total items: {stats.total_items}\n"
|
||||
result += f"- Max items: {stats.max_items}\n"
|
||||
result += f"- Items with summary: {stats.items_with_summary}\n"
|
||||
result += f"- Total accesses: {stats.total_accesses}\n"
|
||||
result += f"- Memory usage: {stats.memory_usage_percent:.1f}%\n"
|
||||
if getattr(stats, "popular_tags", None):
|
||||
result += f"- Popular tags: {', '.join(stats.popular_tags.keys())}\n"
|
||||
if getattr(stats, "content_types", None):
|
||||
result += f"- Content types: {', '.join(stats.content_types.keys())}\n"
|
||||
|
||||
print_tool_output("📊 SketchPad Statistics", result)
|
||||
return result
|
||||
|
||||
else:
|
||||
return f"Error: Unknown operation '{operation}'. Supported: store, retrieve, delete, list, search_tags, search, clear, stats"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"SketchPad operation failed: {str(e)}"
|
||||
print_tool_output("❌ SketchPad Operation Failed", error_msg)
|
||||
return error_msg
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from SimpleLLMFunc.hooks import (
|
||||
CustomEvent,
|
||||
ReactEndEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallErrorEvent,
|
||||
ToolCallStartEvent,
|
||||
is_event_yield,
|
||||
is_response_yield,
|
||||
)
|
||||
from react_stream import extract_output_text, extract_response_reasoning
|
||||
|
||||
|
||||
async def emit_subagent_progress(
|
||||
event_emitter: Any,
|
||||
event_name: str,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
if event_emitter is None:
|
||||
return
|
||||
|
||||
emit = getattr(event_emitter, "emit", None)
|
||||
if not callable(emit):
|
||||
return
|
||||
|
||||
awaitable_result: Any = emit(event_name, data)
|
||||
await awaitable_result
|
||||
|
||||
|
||||
async def run_subagent_with_events(
|
||||
*,
|
||||
specialist_callable: Callable[..., Any],
|
||||
specialist_kwargs: dict[str, Any],
|
||||
subagent_label: str,
|
||||
event_emitter: Any,
|
||||
status_payload: Optional[dict[str, Any]] = None,
|
||||
response_transform: Optional[Callable[[str], str]] = None,
|
||||
captured_tool_results: Optional[list[tuple[str, str]]] = None,
|
||||
) -> str:
|
||||
"""Run a streaming llm_function specialist and bridge its events outward."""
|
||||
|
||||
report = ""
|
||||
start_payload = {"phase": "started", "message": f"{subagent_label} started."}
|
||||
if status_payload:
|
||||
start_payload.update(status_payload)
|
||||
start_payload["subagent_label"] = subagent_label
|
||||
|
||||
await emit_subagent_progress(event_emitter, "subagent_status", start_payload)
|
||||
|
||||
async for output in specialist_callable(**specialist_kwargs):
|
||||
if is_response_yield(output):
|
||||
response_text = extract_output_text(output, f"subagent_{subagent_label}")
|
||||
if response_text:
|
||||
report += response_text
|
||||
await emit_subagent_progress(
|
||||
event_emitter,
|
||||
"subagent_response",
|
||||
{
|
||||
"subagent_label": subagent_label,
|
||||
"delta_text": response_text,
|
||||
},
|
||||
)
|
||||
|
||||
reasoning_text = extract_response_reasoning(output.response)
|
||||
if reasoning_text:
|
||||
await emit_subagent_progress(
|
||||
event_emitter,
|
||||
"subagent_reasoning",
|
||||
{
|
||||
"subagent_label": subagent_label,
|
||||
"delta_reasoning": reasoning_text,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if not is_event_yield(output):
|
||||
continue
|
||||
|
||||
event = output.event
|
||||
|
||||
if isinstance(event, ToolCallStartEvent):
|
||||
await emit_subagent_progress(
|
||||
event_emitter,
|
||||
"subagent_tool_start",
|
||||
{
|
||||
"subagent_label": subagent_label,
|
||||
"nested_tool_name": event.tool_name,
|
||||
"nested_tool_call_id": event.tool_call_id,
|
||||
"arguments": event.arguments,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(event, ToolCallEndEvent):
|
||||
if captured_tool_results is not None and event.result is not None:
|
||||
captured_tool_results.append((event.tool_name, str(event.result)))
|
||||
await emit_subagent_progress(
|
||||
event_emitter,
|
||||
"subagent_tool_end",
|
||||
{
|
||||
"subagent_label": subagent_label,
|
||||
"nested_tool_name": event.tool_name,
|
||||
"nested_tool_call_id": event.tool_call_id,
|
||||
"arguments": event.arguments,
|
||||
"result": event.result,
|
||||
"execution_time": event.execution_time,
|
||||
"success": event.success,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(event, ToolCallErrorEvent):
|
||||
await emit_subagent_progress(
|
||||
event_emitter,
|
||||
"subagent_tool_error",
|
||||
{
|
||||
"subagent_label": subagent_label,
|
||||
"nested_tool_name": event.tool_name,
|
||||
"nested_tool_call_id": event.tool_call_id,
|
||||
"arguments": event.arguments,
|
||||
"error_message": event.error_message,
|
||||
"execution_time": event.execution_time,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(event, CustomEvent):
|
||||
if event.event_name.startswith("subagent_") and isinstance(
|
||||
event.data, dict
|
||||
):
|
||||
forwarded_data = dict(event.data)
|
||||
forwarded_data.setdefault("subagent_label", subagent_label)
|
||||
forwarded_data.setdefault("source_tool_name", event.tool_name)
|
||||
forwarded_data.setdefault("source_tool_call_id", event.tool_call_id)
|
||||
await emit_subagent_progress(
|
||||
event_emitter,
|
||||
event.event_name,
|
||||
forwarded_data,
|
||||
)
|
||||
continue
|
||||
|
||||
await emit_subagent_progress(
|
||||
event_emitter,
|
||||
"subagent_custom",
|
||||
{
|
||||
"subagent_label": subagent_label,
|
||||
"nested_tool_name": event.tool_name,
|
||||
"nested_tool_call_id": event.tool_call_id,
|
||||
"custom_event_name": event.event_name,
|
||||
"data": event.data,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(event, ReactEndEvent):
|
||||
if not report and isinstance(event.final_response, str):
|
||||
report = event.final_response
|
||||
|
||||
finish_payload = {"phase": "finished", "message": f"{subagent_label} finished."}
|
||||
if status_payload:
|
||||
finish_payload.update(status_payload)
|
||||
finish_payload["subagent_label"] = subagent_label
|
||||
|
||||
await emit_subagent_progress(event_emitter, "subagent_status", finish_payload)
|
||||
|
||||
final_report = response_transform(report) if response_transform else report
|
||||
return final_report
|
||||
|
||||
|
||||
__all__ = ["emit_subagent_progress", "run_subagent_with_events"]
|
||||
Reference in New Issue
Block a user