systemr-cli 1.0.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.
neo/orchestrator.py ADDED
@@ -0,0 +1,288 @@
1
+ """Multi-agent orchestrator — parallel sub-agents for complex tasks.
2
+
3
+ Mirrors Claude Code's Agent tool: the main agent spawns focused
4
+ sub-agents that run in parallel via asyncio.gather, each with a
5
+ focused system prompt and subset of tools.
6
+
7
+ Seven pre-defined agent types cover the full trading day loop:
8
+ SETUP → RESEARCH → PLAN → EXECUTE → DOCUMENT → ANALYZE
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from decimal import Decimal
17
+ from typing import Any, Callable
18
+
19
+ import structlog
20
+
21
+ from neo.streaming import ChatRequest, chat_blocking
22
+
23
+ logger = structlog.get_logger(module="orchestrator")
24
+
25
+
26
+ @dataclass
27
+ class SubAgent:
28
+ """A focused sub-agent with a specific task and system prompt.
29
+
30
+ Attributes:
31
+ name: Human-readable agent name (shown in status display).
32
+ prompt: The focused task prompt sent to the LLM.
33
+ """
34
+
35
+ name: str
36
+ prompt: str
37
+
38
+ def to_request(
39
+ self,
40
+ model: str | None = None,
41
+ profile: str | None = None,
42
+ rules: str | None = None,
43
+ ) -> ChatRequest:
44
+ """Build a ChatRequest for this sub-agent.
45
+
46
+ Args:
47
+ model: Optional model override.
48
+ profile: PROFILE.md content for context.
49
+ rules: RULES.md content for rule enforcement.
50
+
51
+ Returns:
52
+ A ChatRequest configured for this agent's task.
53
+ """
54
+ return ChatRequest(
55
+ user_input=self.prompt,
56
+ model=model,
57
+ profile=profile,
58
+ rules=rules,
59
+ research_mode=True,
60
+ )
61
+
62
+
63
+ @dataclass
64
+ class AgentResult:
65
+ """Result from a sub-agent execution.
66
+
67
+ Attributes:
68
+ agent_name: Which agent produced this result.
69
+ success: Whether execution completed without error.
70
+ content: The agent's response text.
71
+ error: Error message if success is False.
72
+ duration_seconds: How long the agent took.
73
+ credits_used: Credits consumed by this agent (Decimal for precision).
74
+ tools_called: Number of tool calls made.
75
+ """
76
+
77
+ agent_name: str
78
+ success: bool
79
+ content: str
80
+ error: str | None = None
81
+ duration_seconds: float = 0.0
82
+ credits_used: Decimal = field(default_factory=lambda: Decimal("0"))
83
+ tools_called: int = 0
84
+
85
+
86
+ # ── Pre-defined agent roster ────────────────────────────────────────
87
+
88
+ PORTFOLIO_SCANNER = SubAgent(
89
+ name="Portfolio Scanner",
90
+ prompt=(
91
+ "Analyze my current portfolio state. Show all open positions "
92
+ "with current P&L, total account value, buying power, and "
93
+ "overall exposure percentage. Be concise — use a table format."
94
+ ),
95
+ )
96
+
97
+ MARKET_ANALYST = SubAgent(
98
+ name="Market Analyst",
99
+ prompt=(
100
+ "Give me a brief market overview for today. Cover: major index "
101
+ "futures (S&P, Nasdaq, Dow), VIX level, any significant overnight "
102
+ "moves, and key economic events on today's calendar. "
103
+ "Be concise — bullet points only."
104
+ ),
105
+ )
106
+
107
+ RISK_CALCULATOR = SubAgent(
108
+ name="Risk Calculator",
109
+ prompt=(
110
+ "Calculate my current risk exposure. Show: total portfolio risk "
111
+ "as % of account, risk per position, remaining risk budget for "
112
+ "today based on my daily loss limit, and how many more trades I "
113
+ "can take at my standard position size. Be concise."
114
+ ),
115
+ )
116
+
117
+ WATCHLIST_SCANNER = SubAgent(
118
+ name="Watchlist Scanner",
119
+ prompt=(
120
+ "Scan my watchlist for actionable setups today. For each ticker "
121
+ "showing a setup, identify: the pattern (breakout, pullback, "
122
+ "support bounce), entry zone, stop level, and estimated R:R. "
123
+ "Only show tickers with clear setups."
124
+ ),
125
+ )
126
+
127
+ STRATEGY_ANALYST = SubAgent(
128
+ name="Strategy Analyst",
129
+ prompt=(
130
+ "Review today's trading session. Analyze: total P&L, number of "
131
+ "trades, win rate, average R-multiple, risk budget used vs allowed, "
132
+ "any rule violations, and one key lesson. End with a discipline "
133
+ "score out of 10."
134
+ ),
135
+ )
136
+
137
+ JOURNAL_WRITER = SubAgent(
138
+ name="Journal Writer",
139
+ prompt=(
140
+ "Write a trade journal entry for today. Include: market conditions, "
141
+ "trades taken (with entry/exit/R), what went well, what could "
142
+ "improve, and emotional state. Keep it honest."
143
+ ),
144
+ )
145
+
146
+ TRADE_PLANNER = SubAgent(
147
+ name="Trade Planner",
148
+ prompt=(
149
+ "Based on my risk budget, current positions, and market conditions, "
150
+ "suggest up to 3 trade ideas for today. For each: ticker, direction, "
151
+ "entry, stop, target, position size, and risk amount. Ensure total "
152
+ "planned risk stays within my daily limit."
153
+ ),
154
+ )
155
+
156
+
157
+ # ── Orchestration ───────────────────────────────────────────────────
158
+
159
+ async def run_agents(
160
+ agents: list[SubAgent],
161
+ access_token: str,
162
+ model: str | None = None,
163
+ profile: str | None = None,
164
+ rules: str | None = None,
165
+ on_start: Callable[[str], None] | None = None,
166
+ on_complete: Callable[[str, bool], None] | None = None,
167
+ ) -> list[AgentResult]:
168
+ """Run multiple sub-agents in parallel via asyncio.gather.
169
+
170
+ Each agent gets its own Bedrock API call. Results are collected
171
+ and returned in the same order as the input agents.
172
+
173
+ Args:
174
+ agents: List of sub-agents to execute.
175
+ access_token: Bearer token for API auth.
176
+ model: Optional model override for all agents.
177
+ profile: PROFILE.md content.
178
+ rules: RULES.md content.
179
+ on_start: Optional callback when an agent starts.
180
+ on_complete: Optional callback when an agent finishes.
181
+
182
+ Returns:
183
+ List of AgentResult objects.
184
+ """
185
+ async def _run_one(agent: SubAgent) -> AgentResult:
186
+ if on_start:
187
+ try:
188
+ on_start(agent.name)
189
+ except Exception:
190
+ pass
191
+
192
+ start_time = time.monotonic()
193
+ try:
194
+ request = agent.to_request(
195
+ model=model, profile=profile, rules=rules,
196
+ )
197
+ result = await chat_blocking(request, access_token)
198
+ duration = time.monotonic() - start_time
199
+ content = result.get(
200
+ "response", result.get("content", result.get("message", "")),
201
+ )
202
+ # Parse cost tracking from response
203
+ credits_raw = result.get("credits_used")
204
+ credits = Decimal(str(credits_raw)) if credits_raw is not None else Decimal("0")
205
+ tools = int(result.get("tools_called", 0))
206
+
207
+ if on_complete:
208
+ try:
209
+ on_complete(agent.name, True)
210
+ except Exception:
211
+ pass
212
+ logger.info(
213
+ "agent_completed", agent=agent.name, success=True,
214
+ duration=round(duration, 2), credits=str(credits), tools=tools,
215
+ )
216
+ return AgentResult(
217
+ agent_name=agent.name, success=True, content=content,
218
+ duration_seconds=duration, credits_used=credits, tools_called=tools,
219
+ )
220
+ except Exception as exc:
221
+ duration = time.monotonic() - start_time
222
+ if on_complete:
223
+ try:
224
+ on_complete(agent.name, False)
225
+ except Exception:
226
+ pass
227
+ logger.warning("agent_failed", agent=agent.name, error=str(exc), duration=round(duration, 2))
228
+ return AgentResult(
229
+ agent_name=agent.name, success=False, content="", error=str(exc),
230
+ duration_seconds=duration,
231
+ )
232
+
233
+ results = await asyncio.gather(*[_run_one(a) for a in agents])
234
+ return list(results)
235
+
236
+
237
+ async def run_morning_briefing(
238
+ access_token: str,
239
+ model: str | None = None,
240
+ profile: str | None = None,
241
+ rules: str | None = None,
242
+ on_start: Callable[[str], None] | None = None,
243
+ on_complete: Callable[[str, bool], None] | None = None,
244
+ ) -> list[AgentResult]:
245
+ """Run the morning briefing — 4 parallel agents.
246
+
247
+ Agents: Portfolio Scanner, Market Analyst, Risk Calculator, Watchlist Scanner.
248
+ """
249
+ return await run_agents(
250
+ agents=[PORTFOLIO_SCANNER, MARKET_ANALYST, RISK_CALCULATOR, WATCHLIST_SCANNER],
251
+ access_token=access_token, model=model,
252
+ profile=profile, rules=rules,
253
+ on_start=on_start, on_complete=on_complete,
254
+ )
255
+
256
+
257
+ async def run_eod_review(
258
+ access_token: str,
259
+ model: str | None = None,
260
+ profile: str | None = None,
261
+ rules: str | None = None,
262
+ on_start: Callable[[str], None] | None = None,
263
+ on_complete: Callable[[str, bool], None] | None = None,
264
+ ) -> list[AgentResult]:
265
+ """Run end-of-day review — Strategy Analyst + Journal Writer."""
266
+ return await run_agents(
267
+ agents=[STRATEGY_ANALYST, JOURNAL_WRITER],
268
+ access_token=access_token, model=model,
269
+ profile=profile, rules=rules,
270
+ on_start=on_start, on_complete=on_complete,
271
+ )
272
+
273
+
274
+ async def run_trade_plan(
275
+ access_token: str,
276
+ model: str | None = None,
277
+ profile: str | None = None,
278
+ rules: str | None = None,
279
+ on_start: Callable[[str], None] | None = None,
280
+ on_complete: Callable[[str, bool], None] | None = None,
281
+ ) -> list[AgentResult]:
282
+ """Run trade planning — Market Analyst + Risk Calculator + Trade Planner."""
283
+ return await run_agents(
284
+ agents=[MARKET_ANALYST, RISK_CALCULATOR, TRADE_PLANNER],
285
+ access_token=access_token, model=model,
286
+ profile=profile, rules=rules,
287
+ on_start=on_start, on_complete=on_complete,
288
+ )