data timeout fixes; research agent improvements

This commit is contained in:
2026-04-24 20:43:42 -04:00
parent 1800363566
commit 319d81c41f
37 changed files with 672 additions and 280 deletions

View File

@@ -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)),
)