data timeout fixes; research agent improvements
This commit is contained in:
208
sandbox/main.py
208
sandbox/main.py
@@ -197,6 +197,73 @@ def _get_env_yml() -> Optional[Path]:
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
def _coerce_json_arg(val, expected_type: str):
|
||||
"""Coerce a possibly-stringified JSON argument to its expected Python type.
|
||||
Handles LLMs that serialize structured arguments as JSON strings.
|
||||
expected_type: 'object' → dict | 'array' → list
|
||||
Returns None if val is None or coercion is not possible.
|
||||
"""
|
||||
if val is None:
|
||||
return None
|
||||
target = dict if expected_type == "object" else list
|
||||
if isinstance(val, target):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
return parsed if isinstance(parsed, target) else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _update_research_summary(data_dir: Path, script_name: str, description: str, text_output: str) -> None:
|
||||
"""
|
||||
Upsert the research-summary.md entry for the given script name.
|
||||
Uses HTML comment anchors (<!-- BEGIN:name --> / <!-- END:name -->) to locate entries.
|
||||
New entries get a stub with a findings placeholder; existing entries only have their
|
||||
Last Run (and optionally Description) updated — agent-written findings are preserved.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
summary_path = data_dir / "research-summary.md"
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
begin_marker = f"<!-- BEGIN:{script_name} -->"
|
||||
end_marker = f"<!-- END:{script_name} -->"
|
||||
|
||||
stub_parts = [begin_marker, f"## {script_name}"]
|
||||
if description:
|
||||
stub_parts.append(f"**Description:** {description}")
|
||||
stub_parts.append(f"**Last Run:** {timestamp}")
|
||||
stub_parts.append("")
|
||||
stub_parts.append("**Findings:** *(awaiting agent summary)*")
|
||||
stub_parts.append(end_marker)
|
||||
stub_entry = "\n".join(stub_parts)
|
||||
|
||||
if not summary_path.exists():
|
||||
summary_path.write_text(f"# Research Summary\n\n{stub_entry}\n", encoding="utf-8")
|
||||
return
|
||||
|
||||
content = summary_path.read_text(encoding="utf-8")
|
||||
begin_idx = content.find(begin_marker)
|
||||
end_idx = content.find(end_marker)
|
||||
|
||||
if begin_idx != -1 and end_idx != -1:
|
||||
# Entry exists — update only Last Run (and Description if provided), preserve findings
|
||||
existing = content[begin_idx : end_idx + len(end_marker)]
|
||||
updated = re.sub(r'\*\*Last Run:\*\* [^\n]*', f'**Last Run:** {timestamp}', existing)
|
||||
if description:
|
||||
if '**Description:**' in updated:
|
||||
updated = re.sub(r'\*\*Description:\*\* [^\n]*', f'**Description:** {description}', updated)
|
||||
else:
|
||||
updated = re.sub(r'(## [^\n]*\n)', f'\\1**Description:** {description}\n', updated, count=1)
|
||||
new_content = content[:begin_idx] + updated + content[end_idx + len(end_marker):]
|
||||
summary_path.write_text(new_content, encoding="utf-8")
|
||||
else:
|
||||
summary_path.write_text(content.rstrip() + "\n\n---\n\n" + stub_entry + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
@@ -398,7 +465,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Name of the store"
|
||||
},
|
||||
"patch": {
|
||||
"type": "array",
|
||||
"description": "JSON Patch operations (RFC 6902)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
@@ -454,6 +520,46 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"required": ["old_str", "new_str"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="ResearchSummaryRead",
|
||||
description="Read the research summary markdown file. Returns the full content of research-summary.md from the user's sandbox data directory.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="ResearchSummaryWrite",
|
||||
description="Write (fully replace) the research summary markdown file.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full markdown content for the research summary file"
|
||||
}
|
||||
},
|
||||
"required": ["content"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="ResearchSummaryPatch",
|
||||
description="Surgically update a section of the research summary markdown file by finding and replacing text. Fails if old_str is not found.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"old_str": {
|
||||
"type": "string",
|
||||
"description": "Exact text to find in the research summary file"
|
||||
},
|
||||
"new_str": {
|
||||
"type": "string",
|
||||
"description": "Replacement text"
|
||||
}
|
||||
},
|
||||
"required": ["old_str", "new_str"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="PythonWrite",
|
||||
description="Write a new strategy, indicator, or research script with validation",
|
||||
@@ -488,7 +594,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Python implementation code"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Optional category-specific metadata. "
|
||||
"For strategy: include 'data_feeds' (list of {symbol, period_seconds, description}) "
|
||||
@@ -527,7 +632,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Full replacement Python code. Use only when rewriting the entire implementation; prefer 'patches' for targeted edits."
|
||||
},
|
||||
"patches": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Targeted code edits as old/new string pairs. Preferred over 'code' for small changes. "
|
||||
"Each patch: {\"old_string\": \"exact text to find\", \"new_string\": \"replacement text\"}. "
|
||||
@@ -552,7 +656,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Full replacement for the details field. Use only when rewriting the entire description; prefer 'detail_patches' for targeted edits."
|
||||
},
|
||||
"detail_patches": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Targeted edits to the details field as old/new string pairs. Preferred over 'details' for small changes. "
|
||||
"Each patch: {\"old_string\": \"exact text to find\", \"new_string\": \"replacement text\"}. "
|
||||
@@ -569,7 +672,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Updated metadata fields (optional). "
|
||||
"For strategy: 'data_feeds' (list of {symbol, period_seconds, description}) "
|
||||
@@ -621,8 +723,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Display name of the item"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Specific filenames under output/ to return (e.g. [\"analysis.md\", \"img1.png\"]). "
|
||||
"If omitted, returns all output files listed in metadata."
|
||||
@@ -729,8 +829,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"packages": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of conda package names to install"
|
||||
}
|
||||
},
|
||||
@@ -781,7 +879,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Lowercase pandas-ta function name, e.g. 'rsi', 'macd', 'bbands'"
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": "pandas-ta keyword arguments, e.g. {\"length\": 14} or {\"fast\": 12, \"slow\": 26, \"signal\": 9}",
|
||||
"default": {}
|
||||
}
|
||||
@@ -806,7 +903,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Display name of the strategy as saved via PythonWrite"
|
||||
},
|
||||
"feeds": {
|
||||
"type": "array",
|
||||
"description": "Data feeds to backtest against",
|
||||
"items": {
|
||||
"type": "object",
|
||||
@@ -861,7 +957,6 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
"description": "Display name of the strategy as saved via PythonWrite"
|
||||
},
|
||||
"feeds": {
|
||||
"type": "array",
|
||||
"description": "Data feeds for the strategy",
|
||||
"items": {
|
||||
"type": "object",
|
||||
@@ -1004,7 +1099,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
elif name == "WorkspacePatch":
|
||||
return workspace_store.patch(
|
||||
arguments.get("store_name", ""),
|
||||
arguments.get("patch", [])
|
||||
_coerce_json_arg(arguments.get("patch"), "array") or []
|
||||
)
|
||||
elif name == "PreferencesRead":
|
||||
prefs_path = DATA_DIR / "preferences.md"
|
||||
@@ -1025,6 +1120,25 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
return {"success": False, "error": "old_str not found in preferences file"}
|
||||
prefs_path.write_text(content.replace(old_str, new_str, 1), encoding="utf-8")
|
||||
return {"success": True}
|
||||
elif name == "ResearchSummaryRead":
|
||||
summary_path = DATA_DIR / "research-summary.md"
|
||||
if not summary_path.exists():
|
||||
return {"content": "", "exists": False}
|
||||
content = summary_path.read_text(encoding="utf-8")
|
||||
return {"content": content, "exists": True}
|
||||
elif name == "ResearchSummaryWrite":
|
||||
summary_path = DATA_DIR / "research-summary.md"
|
||||
summary_path.write_text(arguments.get("content", ""), encoding="utf-8")
|
||||
return {"success": True}
|
||||
elif name == "ResearchSummaryPatch":
|
||||
summary_path = DATA_DIR / "research-summary.md"
|
||||
old_str = arguments.get("old_str", "")
|
||||
new_str = arguments.get("new_str", "")
|
||||
content = summary_path.read_text(encoding="utf-8") if summary_path.exists() else ""
|
||||
if old_str not in content:
|
||||
return {"success": False, "error": "old_str not found in research summary file"}
|
||||
summary_path.write_text(content.replace(old_str, new_str, 1), encoding="utf-8")
|
||||
return {"success": True}
|
||||
elif name == "PythonWrite":
|
||||
result = await category_manager.write(
|
||||
category=arguments.get("category", ""),
|
||||
@@ -1032,7 +1146,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
description=arguments.get("description", ""),
|
||||
details=arguments.get("details", ""),
|
||||
code=arguments.get("code", ""),
|
||||
metadata=arguments.get("metadata")
|
||||
metadata=_coerce_json_arg(arguments.get("metadata"), "object")
|
||||
)
|
||||
content = []
|
||||
meta_parts = [f"success: {result['success']}"]
|
||||
@@ -1043,7 +1157,11 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
if result.get("revision"):
|
||||
meta_parts.append(f"revision: {result['revision']}")
|
||||
if result.get("validation") and not result["validation"].get("success"):
|
||||
meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}")
|
||||
val = result["validation"]
|
||||
error_detail = val.get('error') or ''
|
||||
if val.get('output'):
|
||||
error_detail = f"{error_detail}\n{val['output']}" if error_detail else val['output']
|
||||
meta_parts.append(f"validation error: {error_detail.strip()}")
|
||||
content.append(TextContent(type="text", text="\n".join(meta_parts)))
|
||||
if result.get("execution"):
|
||||
exec_content = result["execution"].get("content", [])
|
||||
@@ -1058,17 +1176,29 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
sync = _workspace_sync_content(workspace_store, arguments.get("category", ""))
|
||||
if sync:
|
||||
content.append(sync)
|
||||
if arguments.get("category") == "research" and result.get("execution"):
|
||||
exec_text = "\n".join(
|
||||
item.text for item in result["execution"].get("content", [])
|
||||
if getattr(item, "type", "") == "text"
|
||||
)
|
||||
if exec_text.strip():
|
||||
_update_research_summary(
|
||||
DATA_DIR,
|
||||
arguments.get("name", ""),
|
||||
arguments.get("description", "") or "",
|
||||
exec_text,
|
||||
)
|
||||
return content
|
||||
elif name == "PythonEdit":
|
||||
result = await category_manager.edit(
|
||||
category=arguments.get("category", ""),
|
||||
name=arguments.get("name", ""),
|
||||
code=arguments.get("code"),
|
||||
patches=arguments.get("patches"),
|
||||
patches=_coerce_json_arg(arguments.get("patches"), "array"),
|
||||
description=arguments.get("description"),
|
||||
details=arguments.get("details"),
|
||||
detail_patches=arguments.get("detail_patches"),
|
||||
metadata=arguments.get("metadata")
|
||||
detail_patches=_coerce_json_arg(arguments.get("detail_patches"), "array"),
|
||||
metadata=_coerce_json_arg(arguments.get("metadata"), "object")
|
||||
)
|
||||
content = []
|
||||
meta_parts = [f"success: {result['success']}"]
|
||||
@@ -1079,7 +1209,11 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
if result.get("revision"):
|
||||
meta_parts.append(f"revision: {result['revision']}")
|
||||
if result.get("validation") and not result["validation"].get("success"):
|
||||
meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}")
|
||||
val = result["validation"]
|
||||
error_detail = val.get('error') or ''
|
||||
if val.get('output'):
|
||||
error_detail = f"{error_detail}\n{val['output']}" if error_detail else val['output']
|
||||
meta_parts.append(f"validation error: {error_detail.strip()}")
|
||||
content.append(TextContent(type="text", text="\n".join(meta_parts)))
|
||||
if result.get("execution"):
|
||||
exec_content = result["execution"].get("content", [])
|
||||
@@ -1094,6 +1228,18 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
sync = _workspace_sync_content(workspace_store, arguments.get("category", ""))
|
||||
if sync:
|
||||
content.append(sync)
|
||||
if arguments.get("category") == "research" and result.get("execution"):
|
||||
exec_text = "\n".join(
|
||||
item.text for item in result["execution"].get("content", [])
|
||||
if getattr(item, "type", "") == "text"
|
||||
)
|
||||
if exec_text.strip():
|
||||
_update_research_summary(
|
||||
DATA_DIR,
|
||||
arguments.get("name", ""),
|
||||
arguments.get("description", "") or "",
|
||||
exec_text,
|
||||
)
|
||||
return content
|
||||
elif name == "PythonRead":
|
||||
return category_manager.read(
|
||||
@@ -1104,7 +1250,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
result = category_manager.read_output(
|
||||
category=arguments.get("category", ""),
|
||||
name=arguments.get("name", ""),
|
||||
files=arguments.get("files"),
|
||||
files=_coerce_json_arg(arguments.get("files"), "array"),
|
||||
)
|
||||
if "error" in result:
|
||||
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
||||
@@ -1140,7 +1286,11 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
if result.get("error"):
|
||||
meta_parts.append(f"error: {result['error']}")
|
||||
if result.get("validation") and not result["validation"].get("success"):
|
||||
meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}")
|
||||
val = result["validation"]
|
||||
error_detail = val.get('error') or ''
|
||||
if val.get('output'):
|
||||
error_detail = f"{error_detail}\n{val['output']}" if error_detail else val['output']
|
||||
meta_parts.append(f"validation error: {error_detail.strip()}")
|
||||
if result.get("success"):
|
||||
_upsert_type(workspace_store, category_manager, arguments.get("category", ""), arguments.get("name", ""))
|
||||
sync = _workspace_sync_content(workspace_store, arguments.get("category", ""))
|
||||
@@ -1175,7 +1325,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
environment_yml=_get_env_yml()
|
||||
)
|
||||
elif name == "CondaInstall":
|
||||
return await install_packages_async(arguments.get("packages", []))
|
||||
return await install_packages_async(_coerce_json_arg(arguments.get("packages"), "array") or [])
|
||||
elif name == "ExecuteResearch":
|
||||
result = await category_manager.execute_research(name=arguments.get("name", ""))
|
||||
if "error" in result:
|
||||
@@ -1184,6 +1334,12 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
content = result.get("content", [TextContent(type="text", text="No output")])
|
||||
image_count = sum(1 for item in content if item.type == "image")
|
||||
logging.info(f"ExecuteResearch '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
|
||||
exec_text = "\n".join(
|
||||
item.text for item in content
|
||||
if getattr(item, "type", "") == "text"
|
||||
)
|
||||
if exec_text.strip():
|
||||
_update_research_summary(DATA_DIR, arguments.get("name", ""), "", exec_text)
|
||||
return content
|
||||
elif name == "EvaluateIndicator":
|
||||
return await evaluate_indicator(
|
||||
@@ -1192,12 +1348,12 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
to_time=arguments.get("to_time"),
|
||||
period_seconds=int(arguments.get("period_seconds", 3600)),
|
||||
pandas_ta_name=arguments.get("pandas_ta_name", ""),
|
||||
parameters=arguments.get("parameters") or {},
|
||||
parameters=_coerce_json_arg(arguments.get("parameters"), "object") or {},
|
||||
)
|
||||
elif name == "BacktestStrategy":
|
||||
result = await backtest_strategy(
|
||||
strategy_name=arguments.get("strategy_name", ""),
|
||||
feeds=arguments.get("feeds", []),
|
||||
feeds=_coerce_json_arg(arguments.get("feeds"), "array") or [],
|
||||
from_time=arguments.get("from_time"),
|
||||
to_time=arguments.get("to_time"),
|
||||
initial_capital=float(arguments.get("initial_capital", 10_000.0)),
|
||||
@@ -1214,7 +1370,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
from_time=arguments.get("from_time"),
|
||||
to_time=arguments.get("to_time"),
|
||||
initial_capital=float(arguments.get("initial_capital", 10_000.0)),
|
||||
feeds=arguments.get("feeds", []),
|
||||
feeds=_coerce_json_arg(arguments.get("feeds"), "array") or [],
|
||||
summary=payload.get("summary", {}),
|
||||
statistics=payload.get("statistics", {}),
|
||||
trades=payload.get("trades", []),
|
||||
@@ -1226,7 +1382,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
|
||||
elif name == "ActivateStrategy":
|
||||
return await activate_strategy(
|
||||
strategy_name=arguments.get("strategy_name", ""),
|
||||
feeds=arguments.get("feeds", []),
|
||||
feeds=_coerce_json_arg(arguments.get("feeds"), "array") or [],
|
||||
allocation=float(arguments.get("allocation", 0.0)),
|
||||
paper=bool(arguments.get("paper", True)),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user