团队决议:由 Swarm 运行时(非 AM)拉起专家 agent 池并执行每用户限额。 代码: - orchestrator/agent_launcher.py(新):plan_launch_specs(纯,按 min(池大小, MAX_AGENTS_PER_USER −已连) 限额 + 组装每 agent env)、resolve_model_key(override→azkv secret_ref 解析(部署 SecretResolver/dev HEICODE_SECRET_<name>)→OPENAI_API_KEY 兜底,解析不到不伪造)、可插拔后端 launch()(none 默认/subprocess/command 模板,fail-soft)、stop_launched。 - orchestrator/main.py:create 播种后调 launch_swarm_agents(仅去中心化、非 Manager 显式 agent; 从 create x-user-id 取 user_id;key 服务端解析,不入 create 体);stop_swarm_run 调 stop_launched。 文档:runtime-contract §3.3 由「AM 拉起(提案待确认)」改为「Swarm 拉起 + 限额(已定)」, 更新 env 来源列(key=Swarm 从 secret_ref 解析、AGENT_ID/CAPABILITIES=Swarm launcher、 HEICODE_USER_ID=从 create 透传)+ 后端/限额/解析约束;security-boundary §6 增 Swarm 拉起 + 服务端解析 key(不上 argv/日志)说明。 测试:scripts/test-agent-launcher.py(限额封顶、env 组装、key 解析优先级、command 模板、 none no-op)接入 CI。e2e/contract 回归通过(默认 backend=none,行为不变)。 影响范围:仅 agent_swarm(orchestrator + docs + 测试 + CI)。默认 backend=none 不自动拉起、 向后兼容;密钥仅服务端 env 注入、不入 create 体/回调/日志/argv(满足 §3.1 + security-boundary)。 不改 Manager↔Swarm 契约鉴权/计费账本/审批链。 Refs #16 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
5.5 KiB
Python
119 lines
5.5 KiB
Python
"""Swarm-side agent launcher tests (agent_swarm#16: Swarm launches agents + sets the limit).
|
|
|
|
Covers the pure/limit/env/resolve/backend-selection logic of orchestrator/agent_launcher:
|
|
* launch_count + plan_launch_specs cap the pool at MAX_AGENTS_PER_USER (never push the user over);
|
|
* each launch spec composes the agent env the runtime reads (ORCHESTRATOR_URL / AGENT_ID /
|
|
AGENT_CAPABILITIES / OPENAI_API_BASE / OPENAI_API_KEY / HEICODE_USER_ID);
|
|
* the model key is resolved server-side (override → azkv secret_ref dev-map → OPENAI_API_KEY),
|
|
never fabricated;
|
|
* `command` backend builds argv from the template; `none` backend is a no-op (no spawn).
|
|
|
|
Hermetic: no Redis / model / subprocess (backend forced to none/command-build only).
|
|
|
|
Run from agent_swarm_v6:
|
|
python scripts/test-agent-launcher.py
|
|
"""
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from orchestrator import agent_launcher as al
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(("PASS" if cond else "FAIL"), "-", name)
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
|
|
class FakeRun:
|
|
def __init__(self, swarm_id="swarm-abc"):
|
|
self.swarm_id = swarm_id
|
|
|
|
|
|
def test_launch_count():
|
|
check("count = min(pool, limit-connected)", al.launch_count(pool_size=3, limit=10, connected_user_agents=0) == 3)
|
|
check("count respects per-user cap", al.launch_count(pool_size=5, limit=10, connected_user_agents=8) == 2)
|
|
check("count never negative (already over cap)", al.launch_count(pool_size=3, limit=10, connected_user_agents=10) == 0)
|
|
check("count clamps to pool when cap is high", al.launch_count(pool_size=3, limit=100, connected_user_agents=0) == 3)
|
|
|
|
|
|
def test_plan_specs():
|
|
run = FakeRun()
|
|
body = {"orchestration_plan": {"objective": "x"}, "billing_context": {"default_model_id": "gpt-x"}}
|
|
specs = al.plan_launch_specs(run, body, connected_user_agents=8, limit=10, pool_size=3,
|
|
model_key="sk-test", orchestrator_url="ws://orch:8000", user_id="u-1")
|
|
check("plan caps at limit (8 connected, cap 10 -> launch 2)", len(specs) == 2)
|
|
s = specs[0]
|
|
check("spec env has ORCHESTRATOR_URL", s.env.get("ORCHESTRATOR_URL") == "ws://orch:8000")
|
|
check("spec env has model key (server-side injected)", s.env.get("OPENAI_API_KEY") == "sk-test")
|
|
check("spec env has model id from billing_context", s.env.get("OPENAI_MODEL") == "gpt-x")
|
|
check("spec env has HEICODE_USER_ID for per-user cap", s.env.get("HEICODE_USER_ID") == "u-1")
|
|
check("spec has AGENT_ID + capabilities", bool(s.agent_id) and bool(s.env.get("AGENT_CAPABILITIES")))
|
|
check("agent ids unique", len({sp.agent_id for sp in specs}) == len(specs))
|
|
# No key -> OPENAI_API_KEY omitted (not fabricated), no user -> HEICODE_USER_ID omitted.
|
|
specs2 = al.plan_launch_specs(run, body, connected_user_agents=0, limit=10, pool_size=1,
|
|
model_key=None, orchestrator_url="ws://orch", user_id=None)
|
|
check("no model key -> OPENAI_API_KEY omitted", "OPENAI_API_KEY" not in specs2[0].env)
|
|
check("no user -> HEICODE_USER_ID omitted", "HEICODE_USER_ID" not in specs2[0].env)
|
|
|
|
|
|
def test_resolve_model_key():
|
|
for k in ("AGENT_LAUNCH_MODEL_KEY", "OPENAI_API_KEY", "HEICODE_SECRET_res_model_1"):
|
|
os.environ.pop(k, None)
|
|
# override wins
|
|
os.environ["AGENT_LAUNCH_MODEL_KEY"] = "sk-override"
|
|
check("override key wins", al.resolve_model_key({"billing_context": {"secret_ref": "azkv://kv/secrets/res_model_1"}}) == "sk-override")
|
|
os.environ.pop("AGENT_LAUNCH_MODEL_KEY")
|
|
# azkv secret_ref -> dev env map
|
|
os.environ["HEICODE_SECRET_res_model_1"] = "sk-from-kv"
|
|
check("azkv secret_ref resolved via dev map", al.resolve_model_key({"billing_context": {"secret_ref": "azkv://kv/secrets/res_model_1"}}) == "sk-from-kv")
|
|
os.environ.pop("HEICODE_SECRET_res_model_1")
|
|
# fallback to orchestrator OPENAI_API_KEY
|
|
os.environ["OPENAI_API_KEY"] = "sk-orch"
|
|
check("fallback to orchestrator OPENAI_API_KEY", al.resolve_model_key({}) == "sk-orch")
|
|
os.environ.pop("OPENAI_API_KEY")
|
|
check("unresolved -> None (never fabricated)", al.resolve_model_key({"billing_context": {"secret_ref": "azkv://kv/secrets/missing"}}) is None)
|
|
|
|
|
|
def test_command_backend_build():
|
|
os.environ["AGENT_LAUNCH_CMD"] = "launch-agent.sh --id {agent_id} --caps {capabilities}"
|
|
spec = al.AgentLaunchSpec(agent_id="swarm-abc-agent-1", capabilities="python,general",
|
|
env={"OPENAI_API_KEY": "sk-x"})
|
|
argv = al.build_launch_command(spec)
|
|
check("command template substitutes agent_id/capabilities",
|
|
argv == ["launch-agent.sh", "--id", "swarm-abc-agent-1", "--caps", "python,general"])
|
|
check("secret not on argv (passed via env)", all("sk-x" not in a for a in argv))
|
|
os.environ.pop("AGENT_LAUNCH_CMD")
|
|
|
|
|
|
async def test_backend_none_noop():
|
|
import asyncio # noqa
|
|
os.environ["AGENT_LAUNCH_BACKEND"] = "none"
|
|
specs = [al.AgentLaunchSpec(agent_id="a1", capabilities="general", env={})]
|
|
launched = await al.launch(specs, swarm_id="swarm-abc")
|
|
check("backend=none launches nothing (external)", launched == [])
|
|
os.environ.pop("AGENT_LAUNCH_BACKEND")
|
|
|
|
|
|
def main():
|
|
import asyncio
|
|
test_launch_count()
|
|
test_plan_specs()
|
|
test_resolve_model_key()
|
|
test_command_backend_build()
|
|
asyncio.run(test_backend_none_noop())
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} agent-launcher check(s) FAILED: {failures}")
|
|
sys.exit(1)
|
|
print("all agent-launcher checks passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|