Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions condor/acp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ class ToolCallUpdate:
status: str | None = None
title: str | None = None
output: str | None = None
input: dict | None = None


@dataclass
Expand Down Expand Up @@ -319,6 +320,8 @@ def fold_tool_call_event(
tc["name"] = event.title
if event.output:
tc["output"] = event.output
if event.input:
tc["input"] = event.input
return None


Expand Down Expand Up @@ -679,13 +682,17 @@ def _on_session_update(
if text:
self._event_queue.put_nowait(ThoughtChunk(text=text))
elif kind == "tool_call":
# claude-agent-acp sends tool arguments as ``rawInput`` (ACP wire
# field), not ``input`` — without the fallback every tool call
# arrives argument-less, which silently disabled input-dependent
# consumers (risk checks, snapshots' Input blocks, bot_name capture).
self._event_queue.put_nowait(
ToolCallEvent(
tool_call_id=update.get("toolCallId", ""),
title=update.get("title", ""),
status=update.get("status", "pending"),
kind=update.get("kind", "other"),
input=update.get("input"),
input=update.get("input") or update.get("rawInput"),
)
)
elif kind == "tool_call_update":
Expand All @@ -695,6 +702,7 @@ def _on_session_update(
status=update.get("status"),
title=update.get("title"),
output=update.get("output"),
input=update.get("input") or update.get("rawInput"),
)
)

Expand All @@ -708,9 +716,16 @@ async def _on_request_permission(
) -> dict[str, Any]:
options = options or []

# If we have a permission callback, delegate to it
# If we have a permission callback, delegate to it. Normalize the ACP
# wire field ``rawInput`` into ``input`` first — the risk engine reads
# tool_call["input"], and without this every permission check saw empty
# arguments (deploy caps, dry-run blocks and controller_id validation
# all silently passed).
if self.permission_callback:
return await self.permission_callback(toolCall or {}, options)
tc = dict(toolCall or {})
if not tc.get("input") and tc.get("rawInput"):
tc["input"] = tc["rawInput"]
return await self.permission_callback(tc, options)

# Default: auto-approve
for opt in options:
Expand Down
59 changes: 59 additions & 0 deletions condor/agents/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Any
Expand Down Expand Up @@ -466,6 +467,8 @@ async def _tick(self) -> None:
response_text = "".join(response_chunks)
tick_duration = time.time() - self._last_tick_at

self._capture_bot_name(tool_calls)

from datetime import datetime, timezone

timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
Expand Down Expand Up @@ -551,6 +554,62 @@ async def _collect_stream(self, acp_client: ACPClient, prompt: str):
if isinstance(event, PromptDone):
break

def _capture_bot_name(self, tool_calls: list[dict[str, Any]]) -> None:
"""Persist the bot a session operates, observed from its own tool calls.

Bot-mode strategies that derive the name at runtime (e.g. ``{base}-mm``
from the traded pair) never had it in config, so every consumer keyed on
the session's ``bot_name`` — CORE DATA totals, the dashboard's per-session
executors/PnL, the metrics timeline — saw an empty session while the bot
traded. Watching ``manage_bots(action="deploy")`` here fixes that for
every strategy at once; the deployed name is written straight into the
session's ``config.yml`` (the same place the delta-neutral strategy keeps
its static name), so the merge applies from the next tick and across
restarts. Latest deploy wins — matches the framework's single
``bot_name``-per-session model.
"""
if self.is_experiment or not self.session_dir:
return
for tc in tool_calls:
name = str(tc.get("name") or "")
if name.rsplit("__", 1)[-1] != "manage_bots":
continue
inp = tc.get("input")
if not isinstance(inp, dict) or inp.get("action") != "deploy":
continue
# A deploy the risk engine blocked (or that errored) never created a
# bot — don't attribute the session to a name that isn't running.
if str(tc.get("status") or "") == "failed":
continue
bot_name = str(inp.get("bot_name") or "").strip()
if not bot_name or bot_name == self.config.get("bot_name"):
continue
from .config import save_full_config

self.config["bot_name"] = bot_name
try:
# config.yml's mtime doubles as the session start epoch for
# bot-history window tiling (_session_start_epoch) — preserve it
# across this mid-session rewrite or the session's PnL window
# would silently shift to the deploy tick.
cfg_path = self.session_dir / "config.yml"
stat = cfg_path.stat() if cfg_path.exists() else None
save_full_config(self.session_dir, self.config)
if stat is not None:
os.utime(cfg_path, (stat.st_atime, stat.st_mtime))
except Exception:
log.exception(
"TickEngine %s: failed to persist bot_name=%s",
self.agent_id,
bot_name,
)
continue
log.info(
"TickEngine %s: captured deployed bot_name=%s into session config",
self.agent_id,
bot_name,
)

# ------------------------------------------------------------------
# Client factory
# ------------------------------------------------------------------
Expand Down
71 changes: 66 additions & 5 deletions condor/web/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ class AgentPerformanceModel(BaseModel):
open_count: int = 0
closed_count: int = 0
executors: list[dict[str, Any]] = []
# Bot-mode attribution: which bot this session operates (resolved instance
# name) and its per-controller breakdown, so the UI can label bot-mode
# sessions and filter live executors by the bot's controller ids.
bot_name: str = ""
controllers: list[dict[str, Any]] = []


class StrategyPerformanceResponse(BaseModel):
Expand Down Expand Up @@ -380,6 +385,54 @@ def _session_start_epoch(strategy_dir: Path, num: int) -> float:
return 0.0


async def _merge_session_bot_slice(
client: Any,
perf: Any,
bot_base: str,
strategy_dir: Path,
session_num: int,
session_nums: list[int],
) -> None:
"""Fold one closed session's slice of its bot's history into ``perf`` in place.

Same window tiling as :func:`_apply_bot_mode_pnl` — ``[start_N, start_next)``
where ``start_next`` is the next session's start (or now for the last one) —
so the per-session detail agrees with the strategy performance rollup.
Realized-only: a closed session never carries the bot's live unrealized PnL
or open positions, which belong to the current operator.
"""
from condor.fetchers.bot_performance import (
fetch_all_bot_performance,
fetch_instance_history,
resolve_bot_instances,
slice_history,
)

start = _session_start_epoch(strategy_dir, session_num)
if start <= 0:
return
later = [n for n in session_nums if n > session_num]
end = _session_start_epoch(strategy_dir, min(later)) if later else time.time()
if end <= start:
end = time.time()

try:
all_bot_perf = await fetch_all_bot_performance(client)
except Exception as e:
log.warning("session bot slice: fetch_all_bot_performance failed: %s", e)
return
instances = resolve_bot_instances(all_bot_perf, bot_base)
if not instances:
return
histories = [await fetch_instance_history(client, inst) for inst in instances]
realized, volume, trades = slice_history(histories, start, end)
perf.realized_pnl += realized
perf.total_pnl = perf.realized_pnl + perf.unrealized_pnl
perf.volume += volume
perf.trade_count += int(round(trades))
perf.bot_name = perf.bot_name or bot_base


async def _apply_bot_mode_pnl(
real_sessions: list, strategy_dir: Path, default_config: dict | None, client: Any
) -> None:
Expand Down Expand Up @@ -1174,12 +1227,18 @@ async def get_session_executors(
if k == "session"
]
is_operator = bool(session_nums) and session_num == max(session_nums)
bot_name = (
_session_bot_base(strategy.dir, strategy.default_config, session_num)
if is_operator
else ""
bot_base = _session_bot_base(strategy.dir, strategy.default_config, session_num)
perf = await fetch_agent_performance(
client, agent_id, bot_name=bot_base if is_operator else ""
)
perf = await fetch_agent_performance(client, agent_id, bot_name=bot_name)
if bot_base and not is_operator:
# Closed bot-mode session: attribute its own time-window slice of the
# bot's history (same tiling as _apply_bot_mode_pnl) so a finished run
# still shows what it earned instead of an empty page. No live rows —
# open positions belong to the current operator.
await _merge_session_bot_slice(
client, perf, bot_base, strategy.dir, session_num, session_nums
)
model = AgentPerformanceModel(
agent_id=agent_id,
session_num=session_num,
Expand All @@ -1193,6 +1252,8 @@ async def get_session_executors(
open_count=perf.open_count,
closed_count=perf.closed_count,
executors=perf.executors,
bot_name=perf.bot_name,
controllers=perf.controllers,
)
return {"executors": perf.executors, "performance": model.model_dump()}

Expand Down
Loading