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
166 changes: 155 additions & 11 deletions bin/ask
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,16 @@ sys.path.insert(0, str(lib_dir))
from compat import read_stdin_text, setup_windows_encoding
setup_windows_encoding()

from aliases import load_aliases, resolve_alias
from cli_output import EXIT_ERROR, EXIT_OK
from providers import parse_qualified_provider
from session_utils import find_project_session_file
from agent_comm import (
AgentMessage, broadcast_message, build_chain_messages,
parse_chain_spec, resolve_agent_to_provider, wrap_message,
)
from task_router import auto_route
from team_config import load_team_config, resolve_team_agent


# Provider to daemon command mapping
Expand Down Expand Up @@ -481,47 +488,58 @@ def make_task_id() -> str:


def _usage() -> None:
aliases = load_aliases()
alias_list = ", ".join(f"{k}→{v}" for k, v in sorted(aliases.items()))
print("Usage: ask <provider> [options] <message>", file=sys.stderr)
print("", file=sys.stderr)
print("Providers:", file=sys.stderr)
print(" gemini, codex, opencode, droid, claude, copilot, codebuddy, qwen", file=sys.stderr)
print("", file=sys.stderr)
print("Aliases:", file=sys.stderr)
print(f" {alias_list}", file=sys.stderr)
print("", file=sys.stderr)
team = load_team_config(Path.cwd())
if team:
agents = ", ".join(f"{a.name}→{a.provider}" for a in team.agents)
print(f"Team '{team.name}' ({team.strategy}):", file=sys.stderr)
print(f" {agents}", file=sys.stderr)
print("", file=sys.stderr)
print("Options:", file=sys.stderr)
print(" -h, --help Show this help message", file=sys.stderr)
print(" -t, --timeout SECONDS Request timeout (default: 3600)", file=sys.stderr)
print(" --notify Sync send, no wait for reply (for notifications)", file=sys.stderr)
print(" --foreground Run in foreground (no nohup/background)", file=sys.stderr)
print(" --background Force background mode", file=sys.stderr)
print(" --no-wrap Don't wrap with CCB protocol markers", file=sys.stderr)
print(" --auto Auto-select provider based on message content", file=sys.stderr)
print(" --to <agent> Send message to another agent (inter-agent)", file=sys.stderr)
print(" --broadcast Send message to all team agents", file=sys.stderr)
print(" --chain <spec> Run agent chain: \"a:task1 | b:task2 | c:task3\"", file=sys.stderr)


def main(argv: list[str]) -> int:
if len(argv) <= 1:
_usage()
return EXIT_ERROR

# First argument must be the provider
# First argument must be the provider (or --auto / --help)
raw_provider = argv[1].lower()

if raw_provider in ("-h", "--help"):
_usage()
return EXIT_OK

base_provider, instance = parse_qualified_provider(raw_provider)

if base_provider not in PROVIDER_DAEMONS:
print(f"[ERROR] Unknown provider: {base_provider}", file=sys.stderr)
print(f"[ERROR] Available: {', '.join(PROVIDER_DAEMONS.keys())}", file=sys.stderr)
return EXIT_ERROR

daemon_cmd = PROVIDER_DAEMONS[base_provider]
provider = raw_provider # keep full qualified key for daemon routing
auto_mode = raw_provider == "--auto"
cwd = Path.cwd()

# Parse remaining arguments
# Parse remaining arguments (shared by both auto and normal modes)
timeout: float = 3600.0
notify_mode = False
no_wrap = False
foreground_mode = _default_foreground()
to_agent: str = ""
broadcast_mode = False
chain_spec: str = ""
parts: list[str] = []

it = iter(argv[2:])
Expand Down Expand Up @@ -551,6 +569,23 @@ def main(argv: list[str]) -> int:
if token == "--no-wrap":
no_wrap = True
continue
if token == "--to":
try:
to_agent = next(it).strip().lower()
except StopIteration:
print("[ERROR] --to requires an agent name", file=sys.stderr)
return EXIT_ERROR
continue
if token == "--broadcast":
broadcast_mode = True
continue
if token == "--chain":
try:
chain_spec = next(it).strip()
except StopIteration:
print("[ERROR] --chain requires a spec string", file=sys.stderr)
return EXIT_ERROR
continue
parts.append(token)

message = " ".join(parts).strip()
Expand All @@ -560,6 +595,115 @@ def main(argv: list[str]) -> int:
print("[ERROR] Message cannot be empty", file=sys.stderr)
return EXIT_ERROR

team = load_team_config(cwd)
aliases = load_aliases(cwd)

# --chain mode: sequential multi-agent pipeline
if chain_spec:
steps = parse_chain_spec(chain_spec)
if not steps:
print("[ERROR] Invalid chain spec", file=sys.stderr)
return EXIT_ERROR
chain_msgs = build_chain_messages(steps)
ask_cmd = str(Path(__file__).resolve())
prev_output = ""
for i, msg in enumerate(chain_msgs):
# Resolve agent name to provider
target = resolve_agent_to_provider(msg.receiver, team, aliases)
if not target:
print(f"[ERROR] Unknown agent in chain: {msg.receiver}", file=sys.stderr)
return EXIT_ERROR
# Inject previous output as context
if prev_output:
msg = AgentMessage(sender=msg.sender, receiver=target, content=msg.content, context=prev_output)
else:
msg = AgentMessage(sender=msg.sender, receiver=target, content=msg.content)
wrapped = wrap_message(msg)
print(f"[CHAIN {i+1}/{len(chain_msgs)}] {msg.sender} → {target}", file=sys.stderr)
try:
result = subprocess.run(
[sys.executable, ask_cmd, target, "--foreground", "--no-wrap"],
input=wrapped, capture_output=True, text=True,
timeout=timeout,
)
prev_output = result.stdout.strip()
if prev_output:
print(prev_output)
except subprocess.TimeoutExpired:
print(f"[ERROR] Chain step {i+1} timed out", file=sys.stderr)
return EXIT_ERROR
except Exception as e:
print(f"[ERROR] Chain step {i+1}: {e}", file=sys.stderr)
return EXIT_ERROR
return EXIT_OK

# --broadcast mode: send to all team agents
if broadcast_mode:
if not team:
print("[ERROR] --broadcast requires a team config (.ccb/team.json)", file=sys.stderr)
return EXIT_ERROR
sender = raw_provider if not auto_mode else "auto"
msgs = broadcast_message(sender, message, team)
if not msgs:
print("[WARN] No broadcast recipients", file=sys.stderr)
return EXIT_OK
ask_cmd = str(Path(__file__).resolve())
for msg in msgs:
wrapped = wrap_message(msg)
print(f"[BROADCAST] → {msg.receiver}", file=sys.stderr)
proc = subprocess.Popen(
[sys.executable, ask_cmd, msg.receiver, "--foreground", "--no-wrap"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
close_fds=True,
)
try:
proc.stdin.write(wrapped.encode("utf-8"))
proc.stdin.close()
except Exception:
pass
print(f"[BROADCAST] Sent to {len(msgs)} agents", file=sys.stderr)
return EXIT_OK

# --to mode: wrap message with sender metadata and redirect to target agent
if to_agent:
target_provider = resolve_agent_to_provider(to_agent, team, aliases)
if not target_provider:
print(f"[ERROR] Unknown target agent: {to_agent}", file=sys.stderr)
return EXIT_ERROR
sender = raw_provider if not auto_mode else "auto"
msg = AgentMessage(sender=sender, receiver=target_provider, content=message)
message = wrap_message(msg)
raw_provider = target_provider
print(f"[TO] {sender} → {to_agent} ({target_provider})", file=sys.stderr)

# --auto mode: select provider based on message content
elif auto_mode:
route = auto_route(message, team)
raw_provider = route.provider
print(f"[AUTO] → {raw_provider}" + (f" ({route.reason})" if route.reason else ""), file=sys.stderr)
else:
# Resolution order: team agents > aliases > direct provider names
team_agent = resolve_team_agent(raw_provider, team)
if team_agent:
raw_provider = team_agent.provider
else:
base_part, _, instance_part = raw_provider.partition(":")
base_part = resolve_alias(base_part, aliases)
raw_provider = f"{base_part}:{instance_part}" if instance_part else base_part

base_provider, instance = parse_qualified_provider(raw_provider)

if base_provider not in PROVIDER_DAEMONS:
print(f"[ERROR] Unknown provider: {base_provider}", file=sys.stderr)
print(f"[ERROR] Available: {', '.join(PROVIDER_DAEMONS.keys())}", file=sys.stderr)
return EXIT_ERROR

daemon_cmd = PROVIDER_DAEMONS[base_provider]
provider = raw_provider # keep full qualified key for daemon routing

# Notify mode: sync send, no wait for reply (used for hook notifications)
if notify_mode:
_require_caller()
Expand Down
Loading
Loading