Skip to content
Merged
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
92 changes: 90 additions & 2 deletions src/row_bot/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3259,6 +3259,94 @@ def _resolve_tool_display_name(func_name: str) -> str:
return _TOOL_DISPLAY_NAMES.get(func_name, func_name)


_SAFE_TOOL_CALL_ARG_KEYS = {
"category",
"display_name",
"include_events",
"limit",
"model",
"parent_message_id",
"parent_run_id",
"parent_thread_id",
"profile",
"run_id",
"setting",
"statuses",
"timeout_seconds",
"wait",
}


class ToolCallPayload(str):
"""String-compatible tool-call event with optional UI metadata."""

def __new__(
cls,
name: str,
*,
raw_name: str = "",
args: dict[str, Any] | None = None,
call_id: str = "",
):
obj = str.__new__(cls, str(name or "tool"))
obj.raw_name = str(raw_name or "")
obj.args = dict(args or {})
obj.call_id = str(call_id or "")
return obj

def get(self, key: str, default: Any = None) -> Any:
if key == "name":
return str(self)
if key == "raw_name":
return self.raw_name
if key == "args":
return self.args
if key == "id":
return self.call_id
return default

def as_dict(self) -> dict[str, Any]:
return {
"name": str(self),
"raw_name": self.raw_name,
"args": dict(self.args),
"id": self.call_id,
}


def _safe_tool_call_args(args: Any) -> dict[str, Any]:
if not isinstance(args, dict):
return {}
safe: dict[str, Any] = {}
for key, value in args.items():
clean_key = str(key or "").strip()
if clean_key not in _SAFE_TOOL_CALL_ARG_KEYS:
continue
if isinstance(value, bool) or value is None:
safe[clean_key] = value
elif isinstance(value, (int, float)):
safe[clean_key] = value
elif isinstance(value, str):
safe[clean_key] = value[:180]
elif isinstance(value, list):
safe[clean_key] = [
str(item)[:120]
for item in value[:8]
if isinstance(item, (str, int, float, bool))
]
return safe


def _tool_call_payload(tc: dict[str, Any]) -> ToolCallPayload:
raw_name = str(tc.get("name") or "")
return ToolCallPayload(
_resolve_tool_display_name(raw_name),
raw_name=raw_name,
args=_safe_tool_call_args(tc.get("args")),
call_id=str(tc.get("id") or raw_name),
)


def _selected_model_label_from_config(config: dict) -> tuple[str, bool]:
model_override = (config.get("configurable") or {}).get("model_override")
if model_override and model_override != get_current_model():
Expand Down Expand Up @@ -4316,7 +4404,7 @@ def _finish_provider_call(moment: float, reason: str) -> None:
tc_id = tc.get("id", tc["name"])
if tc_id not in _seen_tool_calls:
_seen_tool_calls.add(tc_id)
yield ("tool_call", _resolve_tool_display_name(tc["name"]))
yield ("tool_call", _tool_call_payload(tc))

# Loop detection: hash (name, args) as signature
_args = tc.get("args", {})
Expand Down Expand Up @@ -4454,7 +4542,7 @@ def _finish_provider_call(moment: float, reason: str) -> None:
tc_list = getattr(m, "tool_calls", [])
if tc_list:
for tc in tc_list:
yield ("tool_call", _resolve_tool_display_name(tc["name"]))
yield ("tool_call", _tool_call_payload(tc))
if m.type == "tool":
yield ("tool_done", {
"name": _resolve_tool_display_name(m.name),
Expand Down
65 changes: 59 additions & 6 deletions src/row_bot/agent_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class AgentSpawnRequest:
profile: str = DEFAULT_DIRECT_AGENT_PROFILE
explicit_profile: bool = False
source: str = "natural"
model: str = ""


_DIRECT_AGENT_VERBS = {"use", "create", "spawn", "start", "launch", "make"}
Expand Down Expand Up @@ -181,12 +182,18 @@ def is_agent_spawn_command(text: str) -> bool:


def parse_agent_spawn_text(text: str) -> AgentSpawnRequest | None:
"""Parse explicit direct child-Agent requests without task-based routing."""
"""Parse explicit direct child-Agent slash commands without task-based routing."""
raw = str(text or "").strip()
if not raw:
return None
if is_agent_spawn_command(raw):
arg = raw.split(maxsplit=1)[1].strip() if len(raw.split(maxsplit=1)) > 1 else ""
if not arg:
return None
arg, model = _extract_model_option(arg)
if arg is None:
return None
arg = arg.strip()
if not arg:
return None
profile_match = _consume_leading_profile(arg)
Expand All @@ -199,24 +206,55 @@ def parse_agent_spawn_text(text: str) -> AgentSpawnRequest | None:
profile=profile_slug,
explicit_profile=True,
source="slash",
model=model,
)
return AgentSpawnRequest(
objective=arg,
profile=DEFAULT_DIRECT_AGENT_PROFILE,
explicit_profile=False,
source="slash",
model=model,
)
return _parse_natural_agent_request(raw)
return None


def _extract_model_option(arg: str) -> tuple[str | None, str]:
"""Remove a strict ``--model`` option from a slash-command argument."""
parts = str(arg or "").strip().split()
if not parts:
return "", ""
kept: list[str] = []
model = ""
index = 0
while index < len(parts):
part = parts[index]
if part == "--model":
if index + 1 >= len(parts):
return None, ""
model = parts[index + 1].strip()
index += 2
continue
if part.startswith("--model="):
model = part.split("=", 1)[1].strip()
if not model:
return None, ""
index += 1
continue
kept.append(part)
index += 1
return " ".join(kept).strip(), model


def format_agent_spawn_usage() -> str:
return (
"Usage: `/agent [profile] <task>`.\n\n"
"Usage: `/agent [--model=model:provider:model-id] [profile] <task>`.\n\n"
"Examples:\n"
"- `/agent review check this plan for risk`\n"
"- `/agent develop implement the focused fix`\n\n"
"- `/agent develop implement the focused fix`\n"
"- `/agent --model=model:claude_subscription:claude-opus-4-8 worker only reply: ok`\n\n"
"Generic Agent requests use `worker`. A specialized profile is used only "
"when you explicitly name an enabled Agent Profile."
"when you explicitly name an enabled Agent Profile. The optional model "
"must be an active pinned Brain canonical ref or exact pinned label."
)


Expand Down Expand Up @@ -244,13 +282,26 @@ def spawn_agent_from_request(
raise ValueError("Direct Agent requests require a parent thread.")
from row_bot.agent_runner import spawn_agent_run

model_override = ""
if str(request.model or "").strip():
from row_bot.providers.selection import resolve_catalog_model_selection

resolved = resolve_catalog_model_selection(
request.model,
surface="chat",
require_agent_ready=True,
require_pinned=True,
)
model_override = resolved.ref

return spawn_agent_run(
request.objective,
parent_thread_id=str(thread_id),
profile=request.profile or DEFAULT_DIRECT_AGENT_PROFILE,
display_name=agent_spawn_display_name(request),
context_mode="auto",
enabled_tool_names=list(enabled_tool_names or []),
model_override=model_override,
wait=False,
)

Expand All @@ -261,7 +312,9 @@ def format_agent_spawn_started(run: dict, request: AgentSpawnRequest) -> str:
name = str((run or {}).get("display_name") or agent_spawn_display_name(request)).strip()
profile = str((run or {}).get("profile_slug") or request.profile or DEFAULT_DIRECT_AGENT_PROFILE).strip()
suffix = f" (`{run_id}`)" if run_id else ""
return f"Started Agent **{name}** with profile `{profile}`. Status: `{status}`{suffix}."
model = str((run or {}).get("model_override") or request.model or "").strip()
model_text = f" Model: `{model}`." if model else ""
return f"Started Agent **{name}** with profile `{profile}`. Status: `{status}`{suffix}.{model_text}"


def _profile_lines(query: str = "", *, limit: int = 18) -> list[str]:
Expand Down
12 changes: 12 additions & 0 deletions src/row_bot/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def build_child_agent_prompt(
context_mode: str = "",
parent_thread_id: str = "",
parent_run_id: str = "",
model_override: str = "",
) -> dict[str, str]:
"""Build the focused prompt packet for a child Agent run.

Expand Down Expand Up @@ -204,6 +205,17 @@ def build_child_agent_prompt(
"MISSION:",
objective,
]
model_ref = str(model_override or "").strip()
runtime_lines = [
"The parent selected this child Agent's runtime model before start.",
(
f"Runtime model override: {model_ref}"
if model_ref
else "Runtime model override: inherited from the parent/default runtime."
),
"Do not call row_bot_update_setting(setting='model') to satisfy your own runtime model request.",
]
parts.extend(["", "RUNTIME MODEL:", "\n".join(runtime_lines)])
for title, body in context_sections:
if body:
parts.extend(["", f"{title}:", body])
Expand Down
1 change: 1 addition & 0 deletions src/row_bot/agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ def spawn_agent_run(
context_mode=context_mode,
parent_thread_id=parent_thread_id,
parent_run_id=parent_run_id,
model_override=model,
)
run_id = uuid.uuid4().hex[:12]
child_display = display_name or f"{profile_snapshot.get('display_name', 'Agent')}: {_short_title(objective, limit=42)}"
Expand Down
2 changes: 2 additions & 0 deletions src/row_bot/agent_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,7 @@ def mirror_workflow_run_start(
display_name: str,
steps_total: int = 0,
profile_id: str = "",
profile_snapshot_json: Mapping[str, Any] | None = None,
approval_mode: str = "",
model_override: str = "",
tools_override: Sequence[str] | str | None = None,
Expand All @@ -1399,6 +1400,7 @@ def mirror_workflow_run_start(
thread_id=thread_id,
display_name=display_name,
profile_id=profile_id,
profile_snapshot_json=profile_snapshot_json,
approval_mode=approval_mode,
model_override=model_override,
tools_override=tools_override,
Expand Down
Loading
Loading