dojoagents 0.1.0__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.
Files changed (245) hide show
  1. dojoagents/__init__.py +13 -0
  2. dojoagents/agent/__init__.py +1 -0
  3. dojoagents/agent/canvas_protocol.py +46 -0
  4. dojoagents/agent/compressor.py +377 -0
  5. dojoagents/agent/context_length.py +27 -0
  6. dojoagents/agent/dashboard_tool_protocol.py +117 -0
  7. dojoagents/agent/events.py +258 -0
  8. dojoagents/agent/gemini_provider.py +673 -0
  9. dojoagents/agent/guardrails.py +257 -0
  10. dojoagents/agent/harness.py +149 -0
  11. dojoagents/agent/harnesses/__init__.py +3 -0
  12. dojoagents/agent/harnesses/portfolio.py +302 -0
  13. dojoagents/agent/harnesses/portfolio_eval.py +203 -0
  14. dojoagents/agent/hooks/__init__.py +0 -0
  15. dojoagents/agent/hooks/token_compression.py +145 -0
  16. dojoagents/agent/loop.py +1281 -0
  17. dojoagents/agent/model_context.py +395 -0
  18. dojoagents/agent/models.py +181 -0
  19. dojoagents/agent/multimodal.py +230 -0
  20. dojoagents/agent/presenters.py +98 -0
  21. dojoagents/agent/provider_state.py +109 -0
  22. dojoagents/agent/providers.py +388 -0
  23. dojoagents/agent/redact.py +51 -0
  24. dojoagents/agent/runtime.py +273 -0
  25. dojoagents/agent/session_manager.py +462 -0
  26. dojoagents/agent/session_models.py +67 -0
  27. dojoagents/agent/session_repository.py +209 -0
  28. dojoagents/agent/think_scrubber.py +221 -0
  29. dojoagents/agent/token_ledger.py +113 -0
  30. dojoagents/agent/token_policy.py +24 -0
  31. dojoagents/agent/turn_intent.py +31 -0
  32. dojoagents/cli/__init__.py +1 -0
  33. dojoagents/cli/gateway_setup.py +407 -0
  34. dojoagents/cli/main.py +180 -0
  35. dojoagents/cli/mcp_serve.py +36 -0
  36. dojoagents/cli/model_setup.py +147 -0
  37. dojoagents/cli/precompute_sector.py +90 -0
  38. dojoagents/config/__init__.py +1 -0
  39. dojoagents/config/loader.py +333 -0
  40. dojoagents/config/models.py +206 -0
  41. dojoagents/config/watcher.py +21 -0
  42. dojoagents/cron/__init__.py +1 -0
  43. dojoagents/cron/jobs.py +131 -0
  44. dojoagents/cron/scheduler.py +31 -0
  45. dojoagents/dashboard/__init__.py +1 -0
  46. dojoagents/dashboard/agent_runs.py +207 -0
  47. dojoagents/dashboard/api.py +5 -0
  48. dojoagents/dashboard/deps.py +124 -0
  49. dojoagents/dashboard/frontend_builder.py +87 -0
  50. dojoagents/dashboard/middleware/__init__.py +1 -0
  51. dojoagents/dashboard/middleware/profiler_middleware.py +64 -0
  52. dojoagents/dashboard/routers/__init__.py +27 -0
  53. dojoagents/dashboard/routers/chat_sessions.py +75 -0
  54. dojoagents/dashboard/routers/dojo_core.py +238 -0
  55. dojoagents/dashboard/routers/dojo_folio.py +120 -0
  56. dojoagents/dashboard/routers/dojo_mesh.py +53 -0
  57. dojoagents/dashboard/routers/dojo_sphere.py +200 -0
  58. dojoagents/dashboard/routers/market.py +107 -0
  59. dojoagents/dashboard/routers/markets.py +29 -0
  60. dojoagents/dashboard/routers/portfolio.py +262 -0
  61. dojoagents/dashboard/routers/sector.py +71 -0
  62. dojoagents/dashboard/routers/sectors.py +17 -0
  63. dojoagents/dashboard/routers/ticker.py +136 -0
  64. dojoagents/dashboard/routers/utility.py +38 -0
  65. dojoagents/dashboard/schemas/__init__.py +0 -0
  66. dojoagents/dashboard/schemas/agent.py +20 -0
  67. dojoagents/dashboard/schemas/benchmark.py +43 -0
  68. dojoagents/dashboard/schemas/chat_sessions.py +24 -0
  69. dojoagents/dashboard/schemas/common.py +7 -0
  70. dojoagents/dashboard/schemas/dojo_core.py +94 -0
  71. dojoagents/dashboard/schemas/dojo_mesh.py +49 -0
  72. dojoagents/dashboard/schemas/dojo_sphere.py +128 -0
  73. dojoagents/dashboard/schemas/domain_api.py +621 -0
  74. dojoagents/dashboard/schemas/freshness.py +21 -0
  75. dojoagents/dashboard/schemas/market.py +28 -0
  76. dojoagents/dashboard/schemas/portfolio.py +240 -0
  77. dojoagents/dashboard/schemas/sector.py +50 -0
  78. dojoagents/dashboard/schemas/stock.py +44 -0
  79. dojoagents/dashboard/schemas/stock_event.py +15 -0
  80. dojoagents/dashboard/schemas/stock_fin_indicators.py +16 -0
  81. dojoagents/dashboard/schemas/stock_income.py +27 -0
  82. dojoagents/dashboard/schemas/stock_kline.py +65 -0
  83. dojoagents/dashboard/schemas/stock_news.py +15 -0
  84. dojoagents/dashboard/schemas/stock_sector.py +23 -0
  85. dojoagents/dashboard/server.py +708 -0
  86. dojoagents/dashboard/services/__init__.py +17 -0
  87. dojoagents/dashboard/services/benchmark_store.py +281 -0
  88. dojoagents/dashboard/services/constituent_filter.py +65 -0
  89. dojoagents/dashboard/services/constituent_kline_refresh_state.py +56 -0
  90. dojoagents/dashboard/services/dojo_core_income.py +65 -0
  91. dojoagents/dashboard/services/dojo_core_pe.py +149 -0
  92. dojoagents/dashboard/services/dojo_core_quote.py +57 -0
  93. dojoagents/dashboard/services/dojo_core_search.py +141 -0
  94. dojoagents/dashboard/services/dojo_core_sector.py +147 -0
  95. dojoagents/dashboard/services/dojo_data_gateway.py +323 -0
  96. dojoagents/dashboard/services/dojo_sphere_service.py +50 -0
  97. dojoagents/dashboard/services/domain_api.py +1679 -0
  98. dojoagents/dashboard/services/domain_utils.py +109 -0
  99. dojoagents/dashboard/services/file_store_base.py +165 -0
  100. dojoagents/dashboard/services/fin_currency_conversion.py +194 -0
  101. dojoagents/dashboard/services/fin_indicators_utils.py +319 -0
  102. dojoagents/dashboard/services/financial_registry.py +190 -0
  103. dojoagents/dashboard/services/forex_store.py +164 -0
  104. dojoagents/dashboard/services/kline_bar_utils.py +107 -0
  105. dojoagents/dashboard/services/kline_segment.py +134 -0
  106. dojoagents/dashboard/services/kline_store.py +255 -0
  107. dojoagents/dashboard/services/market_refresh_jobs.py +44 -0
  108. dojoagents/dashboard/services/market_sector_lead.py +211 -0
  109. dojoagents/dashboard/services/market_stats.py +55 -0
  110. dojoagents/dashboard/services/portfolio_allocation.py +174 -0
  111. dojoagents/dashboard/services/portfolio_candidate_index.py +51 -0
  112. dojoagents/dashboard/services/portfolio_order_execution.py +701 -0
  113. dojoagents/dashboard/services/portfolio_performance.py +218 -0
  114. dojoagents/dashboard/services/portfolio_service.py +1002 -0
  115. dojoagents/dashboard/services/portfolio_store.py +551 -0
  116. dojoagents/dashboard/services/precompute_sector_daily.py +556 -0
  117. dojoagents/dashboard/services/sector_constituents.py +147 -0
  118. dojoagents/dashboard/services/sector_constituents_list.py +122 -0
  119. dojoagents/dashboard/services/sector_earnings_index.py +408 -0
  120. dojoagents/dashboard/services/sector_metrics_store.py +21 -0
  121. dojoagents/dashboard/services/sector_movers_service.py +256 -0
  122. dojoagents/dashboard/services/sector_precomputed_store.py +341 -0
  123. dojoagents/dashboard/services/sector_scope_performance.py +325 -0
  124. dojoagents/dashboard/services/sector_scope_performance_stats.py +76 -0
  125. dojoagents/dashboard/services/sector_scope_stats.py +89 -0
  126. dojoagents/dashboard/services/sector_store.py +407 -0
  127. dojoagents/dashboard/services/stock_event_store.py +43 -0
  128. dojoagents/dashboard/services/stock_event_utils.py +93 -0
  129. dojoagents/dashboard/services/stock_fin_indicators_store.py +55 -0
  130. dojoagents/dashboard/services/stock_income_store.py +49 -0
  131. dojoagents/dashboard/services/stock_income_utils.py +31 -0
  132. dojoagents/dashboard/services/stock_news_store.py +42 -0
  133. dojoagents/dashboard/services/stock_news_utils.py +159 -0
  134. dojoagents/dashboard/services/stock_quote_filter.py +49 -0
  135. dojoagents/dashboard/services/stock_sector_store.py +265 -0
  136. dojoagents/dashboard/services/stock_store.py +196 -0
  137. dojoagents/dashboard/sse.py +189 -0
  138. dojoagents/dashboard/static/assets/index-BsmOIoJm.css +1 -0
  139. dojoagents/dashboard/static/assets/index-DROMEXz5.js +17 -0
  140. dojoagents/dashboard/static/canvas-template.html +50 -0
  141. dojoagents/dashboard/static/favicon.svg +1 -0
  142. dojoagents/dashboard/static/icons.svg +24 -0
  143. dojoagents/dashboard/static/index.html +14 -0
  144. dojoagents/dashboard/store_manager.py +187 -0
  145. dojoagents/dashboard/tools/__init__.py +4 -0
  146. dojoagents/dashboard/tools/domain_tools.py +604 -0
  147. dojoagents/dashboard/tools/portfolio_tools.py +719 -0
  148. dojoagents/dashboard/web/dist/assets/huggingface-BkYu5Ikl.svg +11 -0
  149. dojoagents/dashboard/web/dist/assets/index-CqqGS94z.css +1 -0
  150. dojoagents/dashboard/web/dist/assets/index-D4u0x8tO.js +208 -0
  151. dojoagents/dashboard/web/dist/assets/logo-Bg9HUHyX.svg +20 -0
  152. dojoagents/dashboard/web/dist/assets/wechat-DXn3AsaS.jpg +0 -0
  153. dojoagents/dashboard/web/dist/favicon.svg +1 -0
  154. dojoagents/dashboard/web/dist/index.html +206 -0
  155. dojoagents/dashboard/web/dist/logo.png +0 -0
  156. dojoagents/dashboard/web/dist/taxonomy/stock_sector/v1.json +2046 -0
  157. dojoagents/data/default_portfolios/047214744248.json +405 -0
  158. dojoagents/data/default_portfolios/2c775a0a6cf3.json +351 -0
  159. dojoagents/data/default_portfolios/3ed7bd45d703.json +14 -0
  160. dojoagents/data/default_portfolios/76f5afffca65.json +375 -0
  161. dojoagents/data/default_portfolios/8d091ba038d7.json +357 -0
  162. dojoagents/data/default_portfolios/b9e6589d8a94.json +543 -0
  163. dojoagents/data/default_portfolios/index.json +24 -0
  164. dojoagents/data/ticker_label_aliases.jsonl +21313 -0
  165. dojoagents/dojo_extensions/__init__.py +1 -0
  166. dojoagents/dojo_extensions/base.py +36 -0
  167. dojoagents/dojo_extensions/registry.py +42 -0
  168. dojoagents/dojo_extensions/research.py +21 -0
  169. dojoagents/gateway/__init__.py +1 -0
  170. dojoagents/gateway/adapters/__init__.py +41 -0
  171. dojoagents/gateway/adapters/base.py +195 -0
  172. dojoagents/gateway/adapters/discord.py +34 -0
  173. dojoagents/gateway/adapters/feishu.py +48 -0
  174. dojoagents/gateway/adapters/slack.py +33 -0
  175. dojoagents/gateway/adapters/telegram.py +43 -0
  176. dojoagents/gateway/adapters/wechat.py +535 -0
  177. dojoagents/gateway/adapters/wecom.py +34 -0
  178. dojoagents/gateway/pairing.py +123 -0
  179. dojoagents/gateway/registry.py +36 -0
  180. dojoagents/gateway/runner.py +849 -0
  181. dojoagents/gateway/server.py +97 -0
  182. dojoagents/gateway/state.py +218 -0
  183. dojoagents/gateway/stream_consumer.py +108 -0
  184. dojoagents/logging.py +60 -0
  185. dojoagents/memory/__init__.py +1 -0
  186. dojoagents/memory/local_memory.py +69 -0
  187. dojoagents/memory/manager.py +148 -0
  188. dojoagents/memory/provider.py +29 -0
  189. dojoagents/memory/skill_summary.py +62 -0
  190. dojoagents/multi_agent/__init__.py +25 -0
  191. dojoagents/multi_agent/automation.py +73 -0
  192. dojoagents/multi_agent/models.py +62 -0
  193. dojoagents/multi_agent/orchestrator.py +39 -0
  194. dojoagents/multi_agent/pool.py +62 -0
  195. dojoagents/multi_agent/tools.py +61 -0
  196. dojoagents/multi_agent/triggers.py +62 -0
  197. dojoagents/packaging_hooks.py +60 -0
  198. dojoagents/planning/__init__.py +18 -0
  199. dojoagents/planning/automation.py +148 -0
  200. dojoagents/planning/engine.py +91 -0
  201. dojoagents/planning/models.py +63 -0
  202. dojoagents/planning/store.py +94 -0
  203. dojoagents/planning/tools.py +118 -0
  204. dojoagents/planning/triggers.py +54 -0
  205. dojoagents/plugins/__init__.py +13 -0
  206. dojoagents/plugins/built_in/__init__.py +1 -0
  207. dojoagents/plugins/built_in/project_guardian/scripts/audit.py +38 -0
  208. dojoagents/plugins/registry.py +771 -0
  209. dojoagents/quant/__init__.py +1 -0
  210. dojoagents/quant/context.py +24 -0
  211. dojoagents/quant/risk.py +10 -0
  212. dojoagents/quant/workflow.py +27 -0
  213. dojoagents/skills/__init__.py +1 -0
  214. dojoagents/skills/cache.py +81 -0
  215. dojoagents/skills/loader.py +7 -0
  216. dojoagents/skills/manager.py +173 -0
  217. dojoagents/tools/__init__.py +5 -0
  218. dojoagents/tools/agent_viz.py +1334 -0
  219. dojoagents/tools/code_execution_tool.py +129 -0
  220. dojoagents/tools/dojo_sdk_tool.py +386 -0
  221. dojoagents/tools/environments/__init__.py +1 -0
  222. dojoagents/tools/environments/base.py +60 -0
  223. dojoagents/tools/environments/docker.py +41 -0
  224. dojoagents/tools/environments/local.py +20 -0
  225. dojoagents/tools/environments/modal.py +20 -0
  226. dojoagents/tools/environments/ssh.py +24 -0
  227. dojoagents/tools/executor.py +108 -0
  228. dojoagents/tools/mcp_oauth.py +320 -0
  229. dojoagents/tools/mcp_tool.py +436 -0
  230. dojoagents/tools/plugin_manage.py +120 -0
  231. dojoagents/tools/process_registry.py +104 -0
  232. dojoagents/tools/registry.py +48 -0
  233. dojoagents/tools/sandbox.py +14 -0
  234. dojoagents/tools/skill_manage.py +352 -0
  235. dojoagents/tools/terminal_tool.py +71 -0
  236. dojoagents/tools/tools_list_tool.py +32 -0
  237. dojoagents/tools/web_searcher.py +281 -0
  238. dojoagents/utils/event_bus.py +22 -0
  239. dojoagents/utils/fuzzy_match.py +163 -0
  240. dojoagents-0.1.0.dist-info/METADATA +155 -0
  241. dojoagents-0.1.0.dist-info/RECORD +245 -0
  242. dojoagents-0.1.0.dist-info/WHEEL +5 -0
  243. dojoagents-0.1.0.dist-info/entry_points.txt +2 -0
  244. dojoagents-0.1.0.dist-info/licenses/LICENSE +201 -0
  245. dojoagents-0.1.0.dist-info/top_level.txt +1 -0
dojoagents/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """DojoAgents quantitative finance agent runtime."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("dojoagents")
7
+ except PackageNotFoundError:
8
+ from pathlib import Path
9
+
10
+ _version_file = Path(__file__).resolve().parents[1] / "VERSION"
11
+ __version__ = _version_file.read_text(encoding="utf-8").strip() if _version_file.is_file() else "0.0.0+unknown"
12
+
13
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """Agent loop and provider abstractions."""
@@ -0,0 +1,46 @@
1
+ """Dashboard visualization protocol for the current chat UI.
2
+
3
+ The dashboard chat surface renders structured ``viz_blocks`` from tool results.
4
+ Unlike the legacy Canvas panel, the current React source does not render
5
+ ``DOJO_CHART`` fenced blocks, so the dashboard prompt must steer the model
6
+ toward structured visualization outputs only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ DASHBOARD_VIZ_PROTOCOL = """
12
+ ## Dashboard Visualization Protocol
13
+
14
+ This dashboard chat renders structured `viz_blocks` from tool results. Prefer the
15
+ existing visualization pipeline over free-form chart code.
16
+
17
+ ### Default behavior
18
+
19
+ 1. Use dashboard domain tools to fetch structured data.
20
+ 2. Reuse `viz_blocks` already attached to tool results whenever they are present.
21
+ 3. If a chart or table is still needed, call `agent_viz_build` with the tool data
22
+ and an appropriate `kind` such as `bar`, `line`, `price_kline`, `table`,
23
+ `hbar_rank`, `donut`, or `kpi_row`.
24
+ 4. Keep the assistant text focused on interpretation and conclusions. Let the
25
+ dashboard render the visualization blocks.
26
+
27
+ ### Important rules
28
+
29
+ - Do NOT output `DOJO_CHART` fenced blocks.
30
+ - Do NOT output JavaScript, ECharts scripts, or HTML for chart rendering.
31
+ - Do NOT describe a chart as rendered unless a structured visualization tool has
32
+ already produced the matching `viz_blocks`.
33
+ - Prefer one comparison chart over many redundant charts when summarizing the same dataset.
34
+
35
+ ### Helpful tool patterns
36
+
37
+ - For cross-market valuation comparison, prefer a single `get_market_overview`
38
+ call without `market` so the result covers US, CN, and HK together.
39
+ - For sector ranking, prefer `get_sector_movers` and render ranked bars or tables.
40
+ - For price trends, prefer `get_ticker_price_trends` or benchmark/ticker kline tools
41
+ and render `price_kline` or `line` blocks.
42
+ """.strip()
43
+
44
+ # Backward-compatible alias for existing imports/tests. The content intentionally
45
+ # reflects the current structured-viz protocol instead of the legacy canvas flow.
46
+ DASHBOARD_CANVAS_PROTOCOL = DASHBOARD_VIZ_PROTOCOL
@@ -0,0 +1,377 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from typing import Any
7
+
8
+ from dojoagents.logging import LOGGER
9
+
10
+ SUMMARY_PREFIX = (
11
+ "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
12
+ "into the summary below. This is background reference, NOT active instructions. "
13
+ "Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. "
14
+ "Your current task is identified in the '## Active Task' section of the summary — resume exactly from there. "
15
+ "Respond ONLY to the latest user message that appears AFTER this summary."
16
+ )
17
+
18
+
19
+ def _block_char_count(part: Any) -> int:
20
+ if isinstance(part, str):
21
+ return len(part)
22
+ if not isinstance(part, dict):
23
+ return len(str(part))
24
+ if "text" in part:
25
+ return len(str(part.get("text", "")))
26
+ if "toolUse" in part:
27
+ return len(json.dumps(part["toolUse"], ensure_ascii=False))
28
+ if "toolResult" in part:
29
+ tr = part["toolResult"]
30
+ texts = []
31
+ for block in tr.get("content", []):
32
+ if isinstance(block, dict) and "text" in block:
33
+ texts.append(str(block["text"]))
34
+ return len("\n".join(texts))
35
+ if "reasoningContent" in part:
36
+ rc = part["reasoningContent"]
37
+ return len(str(rc.get("reasoningText", {}).get("text", "")))
38
+ if "image" in part:
39
+ image = part.get("image")
40
+ if isinstance(image, dict):
41
+ source = image.get("source")
42
+ if isinstance(source, dict):
43
+ raw_bytes = source.get("bytes")
44
+ if isinstance(raw_bytes, (bytes, bytearray)):
45
+ return len(raw_bytes)
46
+ if isinstance(raw_bytes, str):
47
+ return len(raw_bytes)
48
+ location = source.get("location")
49
+ if isinstance(location, dict) and str(location.get("type") or "").strip():
50
+ return len(str(location["type"]))
51
+ return 4096
52
+ return len(json.dumps(part, ensure_ascii=False))
53
+
54
+
55
+ def _estimate_tokens_rough(messages: list[dict[str, Any]]) -> int:
56
+ """Rough estimate of tokens based on character length (approx 4 chars per token)."""
57
+ char_count = 0
58
+ for msg in messages:
59
+ content = msg.get("content") or ""
60
+ if isinstance(content, str):
61
+ char_count += len(content)
62
+ elif isinstance(content, list):
63
+ for part in content:
64
+ char_count += _block_char_count(part)
65
+
66
+ for tc in msg.get("tool_calls") or []:
67
+ if isinstance(tc, dict):
68
+ char_count += len(str(tc.get("function", {}).get("arguments", "")))
69
+ return char_count // 4
70
+
71
+
72
+ def flatten_messages_for_compress(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
73
+ flat: list[dict[str, Any]] = []
74
+ for msg in messages:
75
+ role = str(msg.get("role") or "")
76
+ content = msg.get("content")
77
+ if isinstance(content, list):
78
+ text_parts: list[str] = []
79
+ tool_calls: list[dict[str, Any]] = []
80
+ for part in content:
81
+ if isinstance(part, dict) and "text" in part:
82
+ text_parts.append(str(part["text"]))
83
+ elif isinstance(part, dict) and "toolUse" in part:
84
+ tu = part["toolUse"]
85
+ tool_calls.append(
86
+ {
87
+ "id": tu.get("toolUseId"),
88
+ "type": "function",
89
+ "function": {
90
+ "name": tu.get("name"),
91
+ "arguments": json.dumps(tu.get("input", {}), ensure_ascii=False),
92
+ },
93
+ }
94
+ )
95
+ elif isinstance(part, dict) and "toolResult" in part:
96
+ tr = part["toolResult"]
97
+ result_text = ""
98
+ for block in tr.get("content", []):
99
+ if isinstance(block, dict) and "text" in block:
100
+ result_text += str(block["text"])
101
+ flat.append(
102
+ {
103
+ "role": "tool",
104
+ "tool_call_id": tr.get("toolUseId"),
105
+ "name": tr.get("name"),
106
+ "content": result_text,
107
+ }
108
+ )
109
+ entry: dict[str, Any] = {"role": role, "content": "\n".join(text_parts)}
110
+ if tool_calls:
111
+ entry["tool_calls"] = tool_calls
112
+ if text_parts or tool_calls:
113
+ flat.append(entry)
114
+ continue
115
+ entry = {"role": role, "content": str(content or "")}
116
+ if msg.get("tool_calls"):
117
+ entry["tool_calls"] = msg["tool_calls"]
118
+ if msg.get("tool_call_id"):
119
+ entry["tool_call_id"] = msg["tool_call_id"]
120
+ if msg.get("name"):
121
+ entry["name"] = msg["name"]
122
+ flat.append(entry)
123
+ return flat
124
+
125
+
126
+ def messages_to_strands(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
127
+ strands: list[dict[str, Any]] = []
128
+ for msg in messages:
129
+ role = str(msg.get("role") or "")
130
+ if role == "tool":
131
+ strands.append(
132
+ {
133
+ "role": "user",
134
+ "content": [
135
+ {
136
+ "toolResult": {
137
+ "status": "success",
138
+ "toolUseId": msg.get("tool_call_id"),
139
+ "name": msg.get("name") or "tool",
140
+ "content": [{"text": str(msg.get("content") or "")}],
141
+ }
142
+ }
143
+ ],
144
+ }
145
+ )
146
+ continue
147
+ blocks: list[dict[str, Any]] = []
148
+ content = msg.get("content")
149
+ if isinstance(content, str) and content:
150
+ blocks.append({"text": content})
151
+ for tc in msg.get("tool_calls") or []:
152
+ if not isinstance(tc, dict):
153
+ continue
154
+ func = tc.get("function") or {}
155
+ args = func.get("arguments")
156
+ if isinstance(args, str):
157
+ try:
158
+ args_dict = json.loads(args)
159
+ except json.JSONDecodeError:
160
+ args_dict = {"raw": args}
161
+ else:
162
+ args_dict = args or {}
163
+ blocks.append(
164
+ {
165
+ "toolUse": {
166
+ "toolUseId": tc.get("id"),
167
+ "name": func.get("name"),
168
+ "input": args_dict,
169
+ }
170
+ }
171
+ )
172
+ strands.append({"role": role, "content": blocks})
173
+ return strands
174
+
175
+
176
+ def _truncate_tool_call_args_json(args: str, head_chars: int = 150) -> str:
177
+ try:
178
+ parsed = json.loads(args)
179
+ except (ValueError, TypeError):
180
+ return args
181
+
182
+ def _shrink(obj: Any) -> Any:
183
+ if isinstance(obj, str):
184
+ if len(obj) > head_chars:
185
+ return obj[:head_chars] + "...[truncated]"
186
+ return obj
187
+ if isinstance(obj, dict):
188
+ return {k: _shrink(v) for k, v in obj.items()}
189
+ if isinstance(obj, list):
190
+ return [_shrink(v) for v in obj]
191
+ return obj
192
+
193
+ try:
194
+ return json.dumps(_shrink(parsed), ensure_ascii=False)
195
+ except Exception:
196
+ return args
197
+
198
+
199
+ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
200
+ try:
201
+ args = json.loads(tool_args) if tool_args else {}
202
+ except (json.JSONDecodeError, TypeError):
203
+ args = {}
204
+
205
+ content = tool_content or ""
206
+ content_len = len(content)
207
+ line_count = content.count("\n") + 1 if content.strip() else 0
208
+
209
+ if tool_name == "terminal":
210
+ cmd = args.get("command", "")
211
+ if len(cmd) > 60:
212
+ cmd = cmd[:57] + "..."
213
+ exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content)
214
+ exit_code = exit_match.group(1) if exit_match else "?"
215
+ return f"[terminal] ran `{cmd}` -> exit {exit_code}, {line_count} lines output"
216
+
217
+ if tool_name == "read_file":
218
+ path = args.get("path", "?")
219
+ return f"[read_file] read {path} ({content_len:,} chars)"
220
+
221
+ if tool_name == "write_file":
222
+ path = args.get("path", "?")
223
+ written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?"
224
+ return f"[write_file] wrote to {path} ({written_lines} lines)"
225
+
226
+ if tool_name == "patch":
227
+ path = args.get("path", "?")
228
+ return f"[patch] patched {path} ({content_len:,} chars result)"
229
+
230
+ if tool_name == "web_search":
231
+ query = args.get("query", "?")
232
+ return f"[web_search] query='{query}' ({content_len:,} chars result)"
233
+
234
+ return f"[{tool_name}] executed ({content_len:,} chars result)"
235
+
236
+
237
+ class ContextCompressor:
238
+ def __init__(
239
+ self,
240
+ protect_first_n: int = 3,
241
+ protect_last_n: int = 8,
242
+ ) -> None:
243
+ self.protect_first_n = protect_first_n
244
+ self.protect_last_n = protect_last_n
245
+ self._previous_summary: str | None = None
246
+
247
+ def prune_old_tool_results(self, messages: list[dict[str, Any]], protect_tail_count: int) -> list[dict[str, Any]]:
248
+ if not messages:
249
+ return messages
250
+
251
+ flat = flatten_messages_for_compress(messages)
252
+ result = [dict(m) for m in flat]
253
+ prune_boundary = len(result) - protect_tail_count
254
+ if prune_boundary <= 0:
255
+ return messages
256
+
257
+ call_id_to_tool: dict[str, tuple[str, str]] = {}
258
+ for msg in result:
259
+ if msg.get("role") == "assistant":
260
+ for tc in msg.get("tool_calls") or []:
261
+ if isinstance(tc, dict):
262
+ cid = str(tc.get("id", ""))
263
+ fn = tc.get("function", {})
264
+ call_id_to_tool[cid] = (fn.get("name", "unknown"), fn.get("arguments", ""))
265
+
266
+ seen_hashes: set[str] = set()
267
+ for i in range(prune_boundary):
268
+ msg = result[i]
269
+ role = msg.get("role")
270
+
271
+ if role == "tool":
272
+ content = msg.get("content", "")
273
+ if isinstance(content, str) and len(content) > 150:
274
+ digest = hashlib.md5(content.encode("utf-8")).hexdigest()
275
+ if digest in seen_hashes:
276
+ result[i] = {**msg, "content": "[Duplicate tool output omitted]"}
277
+ else:
278
+ seen_hashes.add(digest)
279
+ call_id = str(msg.get("tool_call_id", ""))
280
+ t_name, t_args = call_id_to_tool.get(call_id, ("unknown", ""))
281
+ result[i] = {**msg, "content": _summarize_tool_result(t_name, t_args, content)}
282
+
283
+ elif role == "assistant" and msg.get("tool_calls"):
284
+ new_tcs = []
285
+ for tc in msg["tool_calls"]:
286
+ if isinstance(tc, dict):
287
+ args = tc.get("function", {}).get("arguments", "")
288
+ if len(args) > 300:
289
+ tc = {
290
+ **tc,
291
+ "function": {
292
+ **tc["function"],
293
+ "arguments": _truncate_tool_call_args_json(args),
294
+ },
295
+ }
296
+ new_tcs.append(tc)
297
+ result[i] = {**msg, "tool_calls": new_tcs}
298
+
299
+ if messages and isinstance(messages[0].get("content"), list):
300
+ return messages_to_strands(result)
301
+ return result
302
+
303
+ async def compress(
304
+ self,
305
+ messages: list[dict[str, Any]],
306
+ llm_provider: Any,
307
+ model: str,
308
+ memory_manager: Any = None,
309
+ session_id: str = "",
310
+ ) -> list[dict[str, Any]]:
311
+ """Compress middle turns using LLM. Caller decides when to invoke."""
312
+ strands_input = bool(messages) and isinstance(messages[0].get("content"), list)
313
+ working = flatten_messages_for_compress(messages)
314
+ pruned_messages = self.prune_old_tool_results(working, self.protect_last_n)
315
+
316
+ head_count = min(self.protect_first_n, len(pruned_messages))
317
+ tail_count = min(self.protect_last_n, len(pruned_messages) - head_count)
318
+ middle_count = len(pruned_messages) - head_count - tail_count
319
+ if middle_count <= 2:
320
+ return messages_to_strands(pruned_messages) if strands_input else pruned_messages
321
+
322
+ head = pruned_messages[:head_count]
323
+ middle = pruned_messages[head_count : head_count + middle_count]
324
+ tail = pruned_messages[head_count + middle_count :]
325
+
326
+ middle_prompt = (
327
+ "You are a context compression assistant. Analyze the dialogue sequence below and extract two things:\n"
328
+ "1. A compact dialogue summary of the middle turns for immediate context continuation.\n"
329
+ "2. Key long-term facts, preferences, user habits, and general workflows that should be saved in the agent's long-term memory.\n\n"
330
+ "Mark each prior user task as COMPLETED unless the latest user message explicitly continues it.\n"
331
+ "The latest user message defines the ONLY active task — do not carry forward unfinished work from older turns.\n\n"
332
+ "Format your output exactly like this:\n"
333
+ "[CONSOLIDATION SUMMARY]\n"
334
+ "<compact summary of dialogue sequence>\n"
335
+ "[LONG-TERM FACTS]\n"
336
+ "<extracted long-term facts and workflows>\n\n"
337
+ "Conversation history to compact:\n"
338
+ )
339
+ if self._previous_summary:
340
+ middle_prompt += f"Previous compaction summary:\n{self._previous_summary}\n\n"
341
+
342
+ for m in middle:
343
+ role = m.get("role", "")
344
+ content = m.get("content") or ""
345
+ tcs = m.get("tool_calls")
346
+ middle_prompt += f"[{role}]: {content}\n"
347
+ if tcs:
348
+ middle_prompt += f"Tool Calls: {json.dumps(tcs)}\n"
349
+
350
+ try:
351
+ summary_result = await llm_provider.chat(
352
+ messages=[{"role": "user", "content": middle_prompt}],
353
+ tools=[],
354
+ model=model,
355
+ )
356
+ content = summary_result.content
357
+ if "[LONG-TERM FACTS]" in content:
358
+ parts = content.split("[LONG-TERM FACTS]")
359
+ summary_content = parts[0].replace("[CONSOLIDATION SUMMARY]", "").strip()
360
+ facts_part = parts[1].strip()
361
+ else:
362
+ summary_content = content.replace("[CONSOLIDATION SUMMARY]", "").strip()
363
+ facts_part = ""
364
+
365
+ self._previous_summary = summary_content
366
+ if facts_part and memory_manager and session_id:
367
+ await memory_manager.save_memory(session_id, facts_part)
368
+ except Exception:
369
+ LOGGER.exception("Failed to generate compaction summary")
370
+ summary_content = "[Compacted due to token limit: summary generation failed]"
371
+
372
+ summary_message = {
373
+ "role": "system",
374
+ "content": f"{SUMMARY_PREFIX}\n\n## Compacted Summary\n{summary_content}",
375
+ }
376
+ compressed = [*head, summary_message, *tail]
377
+ return messages_to_strands(compressed) if strands_input else compressed
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _MAX_CONTEXT_RE = re.compile(r"maximum context length is (\d+)", re.IGNORECASE)
6
+ _REQUESTED_TOKENS_RE = re.compile(r"requested (\d+) tokens", re.IGNORECASE)
7
+
8
+
9
+ class ContextLengthExceededError(Exception):
10
+ def __init__(
11
+ self,
12
+ message: str,
13
+ *,
14
+ max_context: int | None = None,
15
+ requested_tokens: int | None = None,
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.max_context = max_context
19
+ self.requested_tokens = requested_tokens
20
+
21
+
22
+ def parse_context_length_error(message: str) -> tuple[int | None, int | None]:
23
+ max_match = _MAX_CONTEXT_RE.search(message)
24
+ req_match = _REQUESTED_TOKENS_RE.search(message)
25
+ max_context = int(max_match.group(1)) if max_match else None
26
+ requested = int(req_match.group(1)) if req_match else None
27
+ return max_context, requested
@@ -0,0 +1,117 @@
1
+ """Precise dashboard tool calling guidelines injected into the agent system prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DASHBOARD_TOOL_PROTOCOL = """
6
+ ## Dashboard Tool Calling Protocol (MANDATORY)
7
+
8
+ Pick the workflow by user intent. Do NOT default to search_company_ticker or web_search for stock picking.
9
+
10
+ ### Multi-turn sessions (CRITICAL — avoid intent drift)
11
+
12
+ Each user message is a **new, independent task**. Prior turns are closed context.
13
+
14
+ - If the **latest message** asks to **analyze / 分析 / 解读 / 怎么样** an existing portfolio or its candidates:
15
+ use read + financial tools only (`portfolio_read_search`, `portfolio_read_detail`, `get_ticker_financials`, …).
16
+ **Do NOT** call `portfolio_write_create`, batch add candidates, or `portfolio_eval_submit`.
17
+ - If the **latest message** asks to **create / 创建 / 选股 / 建组合**:
18
+ use the portfolio build workflow below.
19
+ - Never resume a prior turn's unfinished create/build work unless the **current message** explicitly asks.
20
+
21
+ When session history mentions an old portfolio task, treat it as **already done** unless the user repeats that request now.
22
+
23
+ ### Task routing (choose ONE path per message)
24
+
25
+ | User intent | Required tools (in order) | Do NOT use |
26
+ |-------------|---------------------------|------------|
27
+ | Theme / concept / industry basket (具身智能, 机器人, 半导体, AI…) | `search_sector_taxonomy` → `filter_sector_constituents` (per market) → optional `get_ticker_financials` batch → portfolio writes | `search_company_ticker`, `web_search` as primary discovery |
28
+ | Sector analysis / compare industries | `get_taxonomy_tree` or `search_sector_taxonomy` → `get_sector_analysis` | Guessing sector ids |
29
+ | Full-market screen (市值/PE/涨跌幅 filters, no specific sector) | `screen_market_stocks` per market → optional `get_ticker_financials` | `filter_sector_constituents` without taxonomy match |
30
+ | Resolve one company name → ticker | `search_company_ticker` (single q, known name) | Repeated keyword searches |
31
+ | Analyze existing portfolio / 候选池成分分析 | `portfolio_read_search` → `portfolio_read_detail` → `get_ticker_financials` batch → answer | `portfolio_write_create`, add candidates, eval_submit |
32
+ | Single stock deep dive | `get_ticker_realtime_quote`, `get_ticker_financials`, `get_ticker_price_trends` | — |
33
+
34
+ ### Analyze portfolio candidates (分析候选池 / 成分股怎么样)
35
+
36
+ Read-only workflow — no portfolio writes:
37
+
38
+ 1. `portfolio_read_search` with portfolio name from the user
39
+ 2. `portfolio_read_detail` for candidates list
40
+ 3. Batch `get_ticker_financials` (and optional quotes) on candidate tickers
41
+ 4. Summarize quality, valuation, risks — **stop**. No create, no eval_submit.
42
+
43
+ ### Theme / concept stock picking (e.g. 具身智能, 高息, 半导体)
44
+
45
+ Concept names are NOT tickers. `search_company_ticker("具身智能")` or `search_company_ticker("Tesla")` will NOT return a complete universe.
46
+
47
+ **Required workflow:**
48
+
49
+ 1. `search_sector_taxonomy` with the user's concept and close synonyms
50
+ (e.g. 具身智能 → also try 机器人, 自动化, robotics, industrial automation).
51
+ 2. From matches, pick the best L3 sector (`best_match` or highest `match_score`).
52
+ 3. For each target market (`us`, `cn`, `hk`), call `filter_sector_constituents`
53
+ with `sector_path_id` or the three ids from step 2 — do NOT pass sector names.
54
+ 4. Apply numeric filters on the result set:
55
+ - market cap: use `min_market_cap` in `screen_market_stocks` only if you pivoted to market-wide screen;
56
+ otherwise filter constituent rows by `market_cap` field.
57
+ - profitability: batch `get_ticker_financials` on candidate tickers, keep net_profit > 0.
58
+ 5. Portfolio watchlist (选股/候选池): `portfolio_write_create` → `portfolio_write_add_candidates` →
59
+ `portfolio_read_detail` (read `eval_summary.candidate_count`) → `portfolio_eval_submit` with **min_candidate_count**.
60
+
61
+ **Eval rules (avoid retry loops):**
62
+ - `min_candidates_by_market` must come from `portfolio_read_detail.eval_summary`, NOT from pre-filter estimates.
63
+ - Do NOT invent per-market minimums (e.g. US≥40) unless the user explicitly asked for a count.
64
+ - If `add_result.skipped_duplicates` is non-empty, those tickers did NOT increase the count — pick new symbols.
65
+ - After eval failure: fix only the gap; do NOT re-print the full portfolio report.
66
+
67
+ **Optional:** `get_sector_analysis` on the chosen path for sector-level context before picking names.
68
+
69
+ ### Portfolio: candidates vs positions (CRITICAL)
70
+
71
+ | Concept | UI label | Meaning | Tool | Eval field |
72
+ |---------|----------|---------|------|------------|
73
+ | Watchlist | 候选股 | Track symbols, no capital spent | `portfolio_write_add_candidate(s)` | `min_candidate_count` |
74
+ | Filled buy | 持仓 / 建仓 | Spend capital at price × qty | `portfolio_write_create_order(s)` | `min_position_count` |
75
+
76
+ **User says 建仓 / 买入 / 按成本价 / 创建交易 / 持仓页面截图 with shares & cost:**
77
+ 1. `portfolio_read_search` or `portfolio_read_list` → target portfolio_id
78
+ 2. For each row: `portfolio_write_create_order` (or batch `portfolio_write_create_orders`)
79
+ with `order_side=buy`, `price`=cost/limit, `qty`=shares, optional `order_time`=open date
80
+ 3. `portfolio_read_detail` → verify `eval_summary.position_count` (NOT candidate_count)
81
+ 4. `portfolio_eval_submit` with **min_position_count** matching filled positions
82
+
83
+ **FORBIDDEN for 建仓:** `portfolio_write_add_candidate`, `portfolio_write_add_holding`, `portfolio_write_add_holdings`
84
+ (these only add 候选股; they never buy or set cost).
85
+
86
+ **Theme basket without 建仓:** use add_candidates only — positions stay 0 until user asks to buy.
87
+
88
+ ### Sector taxonomy ids
89
+
90
+ 1. `search_sector_taxonomy` with the user's concept (synonyms auto-expanded: 具身智能 → 机器人, robotics…).
91
+ 2. Copy `sector_path_id` OR `level1_id` + `level2_id` + `level3_id` verbatim from `best_match` — exact ID lookup, no guessing.
92
+ 3. `filter_sector_constituents` with those ids + `market` + `scope: "L3"`.
93
+ 4. `get_sector_analysis` with the same ids when sector-level stats are needed.
94
+
95
+ ### search_company_ticker — ONLY for single-name resolution
96
+
97
+ Use when the user names ONE company or ticker (Apple, 茅台, 0700.HK).
98
+ FORBIDDEN as stock-universe builder:
99
+ - thematic keywords (具身智能, 机器人, embodied AI)
100
+ - looping famous names (NVIDIA, Tesla, BYD) to assemble a concept basket
101
+ - replacing `filter_sector_constituents` or `screen_market_stocks`
102
+
103
+ ### web_search — supplementary only
104
+
105
+ Use for news/macro context AFTER dashboard tools return candidates.
106
+ FORBIDDEN as the primary way to discover investable tickers when sector/screen tools exist.
107
+
108
+ ### Portfolio tools
109
+
110
+ **Watchlist / 候选股:** create → add_candidates → read_detail → eval_submit (min_candidate_count)
111
+ **建仓 / 买入:** read_search → create_order(s) with price+qty → read_detail → eval_submit (min_position_count)
112
+ **Delete:** read_list → write_delete → done (no read_detail, no eval_submit)
113
+
114
+ ### Batch calls
115
+
116
+ - `get_ticker_realtime_quote` / `get_ticker_financials`: pass all tickers in one `tickers` array (≤50).
117
+ """.strip()