mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-28 10:49:46 +02:00
[eric] apps: the host SDK lands, apps call the user's models, fire workflows, and spawn positioned agents through pre-wired helpers
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""REST surface for OpenSwarm-built apps (the app-side SDK's host half).
|
||||
|
||||
Apps already hold the install token (frontend via ?token=, backends via
|
||||
OPENSWARM_HOST_TOKEN_FILE), and workflows/agents already expose first-class
|
||||
routes the SDK helpers call directly. This SubApp adds only what REST could
|
||||
not do before: a provider-agnostic LLM completion, and an agent spawn that
|
||||
can land its card at a position on the canvas.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def apps_sdk_lifespan() -> AsyncIterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
apps_sdk = SubApp("apps-sdk", apps_sdk_lifespan)
|
||||
|
||||
|
||||
class LlmRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
prompt: str
|
||||
system: Optional[str] = None
|
||||
# Short model name (sonnet/haiku/gpt-5-mini/...); absent means the cheap tier of whatever provider the user runs.
|
||||
model: Optional[str] = None
|
||||
max_tokens: int = 1024
|
||||
|
||||
|
||||
class LlmReply(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
text: str
|
||||
model: str
|
||||
|
||||
|
||||
@apps_sdk.router.post("/llm")
|
||||
@typechecked
|
||||
async def llm(body: LlmRequest) -> LlmReply:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model, resolve_model_id_for_sdk
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
if not body.prompt.strip():
|
||||
raise HTTPException(status_code=422, detail="prompt is empty")
|
||||
settings = load_settings()
|
||||
if body.model:
|
||||
api_model = resolve_model_id_for_sdk(body.model, settings)
|
||||
else:
|
||||
api_model, _ = await resolve_aux_model(settings)
|
||||
client = get_anthropic_client_for_model(settings, api_model)
|
||||
kwargs: Dict[str, Any] = {
|
||||
"model": api_model,
|
||||
"max_tokens": max(1, min(body.max_tokens, 8192)),
|
||||
"messages": [{"role": "user", "content": body.prompt}],
|
||||
}
|
||||
if body.system:
|
||||
kwargs["system"] = body.system
|
||||
try:
|
||||
# STREAM, never .create(): 9router's non-Anthropic lanes answer as real SSE that the non-streaming client parses to empty content.
|
||||
async with client.messages.stream(**kwargs) as stream:
|
||||
resp = await stream.get_final_message()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"LLM call failed: {e}")
|
||||
text = "".join(b.text for b in resp.content if getattr(b, "type", "") == "text")
|
||||
return LlmReply(text=text, model=api_model)
|
||||
|
||||
|
||||
class SpawnAgentRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
prompt: str
|
||||
name: str = "Agent"
|
||||
model: Optional[str] = None
|
||||
dashboard_id: Optional[str] = None
|
||||
# Canvas-space position for the spawned card; both present or the placement broadcast is skipped.
|
||||
x: Optional[float] = None
|
||||
y: Optional[float] = None
|
||||
|
||||
|
||||
class SpawnAgentReply(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
@apps_sdk.router.post("/agents/spawn")
|
||||
@typechecked
|
||||
async def spawn_agent(body: SpawnAgentRequest) -> SpawnAgentReply:
|
||||
import asyncio
|
||||
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.models import AgentConfig
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
|
||||
if not body.prompt.strip():
|
||||
raise HTTPException(status_code=422, detail="prompt is empty")
|
||||
config = AgentConfig(
|
||||
name=body.name,
|
||||
prompt=body.prompt,
|
||||
model=body.model or "sonnet",
|
||||
dashboard_id=body.dashboard_id,
|
||||
)
|
||||
session = await agent_manager.launch_agent(config)
|
||||
asyncio.create_task(agent_manager.send_message(session.id, body.prompt))
|
||||
if body.x is not None and body.y is not None:
|
||||
await ws_manager.broadcast_global("apps_sdk:place_agent_card", {
|
||||
"session_id": session.id,
|
||||
"dashboard_id": body.dashboard_id,
|
||||
"x": body.x,
|
||||
"y": body.y,
|
||||
})
|
||||
return SpawnAgentReply(session_id=session.id)
|
||||
@@ -353,6 +353,21 @@ export const JOBS_LIST = '/api/jobs/list';
|
||||
|
||||
---
|
||||
|
||||
## The host SDK — the user's models, workflows, and agents
|
||||
|
||||
The workspace ships pre-wired helpers for calling the OpenSwarm HOST from inside the app:
|
||||
LLM completions through the user's own subscription (any provider, model selectable),
|
||||
listing + firing the user's workflows and reading run results, and spawning real agent
|
||||
cards on the canvas (optionally positioned). **Read `SDK.md` at the workspace root before
|
||||
building any feature that needs intelligence, automation, or agents** — the helpers are:
|
||||
|
||||
- Frontend: `import { llm, listWorkflows, runWorkflow, spawnAgent } from '@/openswarmHost'`
|
||||
- Backend: `from backend.apps.openswarm_host.openswarm_host import llm, run_workflow, spawn_agent`
|
||||
|
||||
Auth is automatic (host-injected token); never hand-roll fetches against host routes. The SDK
|
||||
works in preview and installed apps; for features that must survive PUBLISHING to the public
|
||||
web, use `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE` below instead.
|
||||
|
||||
## Publishable AI + compute — `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE`
|
||||
|
||||
The FastAPI backend above runs in preview but is **not hosted when an app is
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# OpenSwarm App SDK — call the host from your app
|
||||
|
||||
Your app runs INSIDE OpenSwarm, and the host lends it real capabilities: the user's
|
||||
LLM subscription, their saved workflows, and live agents on the canvas. Two pre-wired
|
||||
helper modules expose all of it; never hand-roll fetch calls to the host.
|
||||
|
||||
| Where | Import |
|
||||
| --- | --- |
|
||||
| Frontend (React/TS) | `import { llm, listWorkflows, runWorkflow, listWorkflowRuns, spawnAgent, agentSession } from '@/openswarmHost';` |
|
||||
| Backend (FastAPI) | `from backend.apps.openswarm_host.openswarm_host import llm, list_workflows, run_workflow, list_workflow_runs, spawn_agent, agent_session` |
|
||||
|
||||
Auth is automatic: the frontend reads the `?token=` the host injects into the preview URL;
|
||||
the backend reads the rotating token file the host passes via `OPENSWARM_HOST_TOKEN_FILE`.
|
||||
You never handle credentials.
|
||||
|
||||
## LLM calls (the user's own subscription, any provider)
|
||||
|
||||
```ts
|
||||
const answer = await llm('Summarize this in one line: ' + text);
|
||||
const haiku = await llm('Write a haiku about rain', { model: 'haiku', system: 'You are terse.' });
|
||||
```
|
||||
|
||||
```python
|
||||
answer = llm("Summarize this in one line: " + text)
|
||||
```
|
||||
|
||||
- Omit `model` for the cheapest tier of whatever provider the user runs (never assume Anthropic).
|
||||
- One-shot only; keep prompts small, this is the user's real money.
|
||||
|
||||
## Workflows
|
||||
|
||||
```ts
|
||||
const flows = await listWorkflows(); // [{id, name, enabled, ...}]
|
||||
await runWorkflow(flows[0].id); // fire it now
|
||||
const runs = await listWorkflowRuns(); // read status/results
|
||||
```
|
||||
|
||||
A workflow the user switched OFF will refuse to run; surface the host's error to the user
|
||||
instead of retrying.
|
||||
|
||||
## Agents on the canvas
|
||||
|
||||
```ts
|
||||
const sessionId = await spawnAgent('Research the top 3 CRM tools and report back', {
|
||||
name: 'CRM scout',
|
||||
x: 400, y: 300, // optional canvas position for the card
|
||||
});
|
||||
const state = await agentSession(sessionId); // {status, messages, ...} — poll while status === 'running'
|
||||
```
|
||||
|
||||
The agent is a real OpenSwarm agent card the user can watch and take over. Spawn sparingly:
|
||||
one agent per user action, never in a loop.
|
||||
|
||||
## What the SDK does NOT give you (yet)
|
||||
|
||||
- Direct calls to the user's connected tools/MCP connectors (Gmail, Slack, ...). That surface
|
||||
needs per-app permission grants and is not wired; do not fake it by calling other host routes.
|
||||
If your app needs a tool action today, spawn an agent and ask it to do the task.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- Degrade gracefully: every helper throws on a host error; catch and show a clean message,
|
||||
never a blank screen.
|
||||
- These helpers only work while the app runs inside OpenSwarm (preview or installed). A
|
||||
published web app on openswarm.host has no host; guard with a try/catch and hide the feature.
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Call the OpenSwarm host (the user's models, workflows, agents) from app backend code.
|
||||
|
||||
The host hands this runtime a token FILE path via OPENSWARM_HOST_TOKEN_FILE (a path, not the
|
||||
value, so token rotations are picked up on every call). See SDK.md at the workspace root for
|
||||
the guide; the frontend twin of this module is frontend/src/openswarmHost.ts.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from typeguard import typechecked
|
||||
|
||||
HOST = os.environ.get("OPENSWARM_HOST_API", "http://127.0.0.1:8324")
|
||||
|
||||
|
||||
@typechecked
|
||||
def host_token() -> str:
|
||||
path = os.environ.get("OPENSWARM_HOST_TOKEN_FILE", "")
|
||||
if not path or not os.path.exists(path):
|
||||
return ""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_request(method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
headers = {"Authorization": f"Bearer {host_token()}"}
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
resp = client.request(method, f"{HOST}{path}", json=body, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"OpenSwarm host {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {"result": data}
|
||||
|
||||
|
||||
@typechecked
|
||||
def llm(prompt: str, system: Optional[str] = None, model: Optional[str] = None, max_tokens: int = 1024) -> str:
|
||||
"""One-shot completion through the user's own configured providers/subscription.
|
||||
`model` is a short name (sonnet, haiku, ...); omit for the cheap tier."""
|
||||
reply = p_request("POST", "/api/apps-sdk/llm", {
|
||||
"prompt": prompt, "system": system, "model": model, "max_tokens": max_tokens,
|
||||
})
|
||||
return str(reply.get("text", ""))
|
||||
|
||||
|
||||
@typechecked
|
||||
def list_workflows() -> List[Dict[str, Any]]:
|
||||
"""The user's saved workflows."""
|
||||
data = p_request("GET", "/api/workflows/list")
|
||||
flows = data.get("workflows", [])
|
||||
return flows if isinstance(flows, list) else []
|
||||
|
||||
|
||||
@typechecked
|
||||
def run_workflow(workflow_id: str) -> Dict[str, Any]:
|
||||
"""Fire a workflow now; returns the host's report for the started run."""
|
||||
return p_request("POST", f"/api/workflows/{workflow_id}/run", {})
|
||||
|
||||
|
||||
@typechecked
|
||||
def list_workflow_runs() -> List[Dict[str, Any]]:
|
||||
"""All workflow runs (newest first), for reading a fired run's status/result."""
|
||||
data = p_request("GET", "/api/workflows/runs/all")
|
||||
runs = data.get("runs", [])
|
||||
return runs if isinstance(runs, list) else []
|
||||
|
||||
|
||||
@typechecked
|
||||
def spawn_agent(
|
||||
prompt: str,
|
||||
name: str = "Agent",
|
||||
model: Optional[str] = None,
|
||||
dashboard_id: Optional[str] = None,
|
||||
x: Optional[float] = None,
|
||||
y: Optional[float] = None,
|
||||
) -> str:
|
||||
"""Spawn a real agent on the canvas with a first prompt; returns its session id.
|
||||
Give both x and y to place the card at a canvas position."""
|
||||
reply = p_request("POST", "/api/apps-sdk/agents/spawn", {
|
||||
"prompt": prompt, "name": name, "model": model,
|
||||
"dashboard_id": dashboard_id, "x": x, "y": y,
|
||||
})
|
||||
return str(reply.get("session_id", ""))
|
||||
|
||||
|
||||
@typechecked
|
||||
def agent_session(session_id: str) -> Dict[str, Any]:
|
||||
"""A spawned agent's live state: status plus its transcript so far."""
|
||||
return p_request("GET", f"/api/agents/sessions/{session_id}")
|
||||
@@ -0,0 +1,106 @@
|
||||
// The app-side SDK: call the OpenSwarm host (your tools, workflows, models, agents) from app
|
||||
// frontend code. The auth token rides in the preview URL (?token=), injected by the host; every
|
||||
// helper resolves it lazily so importing this file never throws. See SDK.md for the guide.
|
||||
|
||||
const HOST = 'http://localhost:8324';
|
||||
|
||||
function hostToken(): string {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('token') ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function hostFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${hostToken()}`,
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
};
|
||||
const res = await fetch(`${HOST}${path}`, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`OpenSwarm host ${path} -> ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface LlmOptions {
|
||||
system?: string;
|
||||
/** Short model name (sonnet, haiku, ...). Omit for the cheap tier of the user's provider. */
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/** One-shot completion through the user's own configured providers/subscription. */
|
||||
export async function llm(prompt: string, opts: LlmOptions = {}): Promise<string> {
|
||||
const reply = await hostFetch<{ text: string; model: string }>('/api/apps-sdk/llm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
system: opts.system,
|
||||
model: opts.model,
|
||||
max_tokens: opts.maxTokens ?? 1024,
|
||||
}),
|
||||
});
|
||||
return reply.text;
|
||||
}
|
||||
|
||||
export interface WorkflowSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** The user's saved workflows. */
|
||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
const data = await hostFetch<{ workflows?: WorkflowSummary[] }>('/api/workflows/list');
|
||||
return data.workflows ?? [];
|
||||
}
|
||||
|
||||
/** Fire a workflow now. Returns whatever the host reports for the started run. */
|
||||
export async function runWorkflow(workflowId: string): Promise<Record<string, unknown>> {
|
||||
return hostFetch<Record<string, unknown>>(`/api/workflows/${encodeURIComponent(workflowId)}/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
/** All workflow runs (newest first), for reading a fired run's status/result. */
|
||||
export async function listWorkflowRuns(): Promise<Record<string, unknown>[]> {
|
||||
const data = await hostFetch<{ runs?: Record<string, unknown>[] }>('/api/workflows/runs/all');
|
||||
return data.runs ?? [];
|
||||
}
|
||||
|
||||
export interface SpawnAgentOptions {
|
||||
name?: string;
|
||||
/** Short model name; omit for the user's default. */
|
||||
model?: string;
|
||||
dashboardId?: string;
|
||||
/** Canvas position for the spawned card; give both or neither. */
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
/** Spawn a real agent on the canvas with a first prompt. Returns its session id. */
|
||||
export async function spawnAgent(prompt: string, opts: SpawnAgentOptions = {}): Promise<string> {
|
||||
const reply = await hostFetch<{ session_id: string }>('/api/apps-sdk/agents/spawn', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
name: opts.name ?? 'Agent',
|
||||
model: opts.model,
|
||||
dashboard_id: opts.dashboardId,
|
||||
x: opts.x,
|
||||
y: opts.y,
|
||||
}),
|
||||
});
|
||||
return reply.session_id;
|
||||
}
|
||||
|
||||
/** A spawned agent's live state: status plus its transcript so far. */
|
||||
export async function agentSession(sessionId: string): Promise<Record<string, unknown>> {
|
||||
return hostFetch<Record<string, unknown>>(`/api/agents/sessions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
+2
-1
@@ -50,11 +50,12 @@ from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy
|
||||
from backend.apps.agents.core.openai_passthrough import openai_passthrough
|
||||
from backend.apps.workflows.workflows import workflows
|
||||
from backend.apps.workflows.cloud.routes import cloud_workflows
|
||||
from backend.apps.apps_sdk.apps_sdk import apps_sdk
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
import json
|
||||
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough])
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough, apps_sdk])
|
||||
app = main_app.app
|
||||
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""The apps SDK host surface (ENG-202): provider-agnostic LLM completions and positioned agent
|
||||
spawns for OpenSwarm-built apps, plus the template helpers that ride them."""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
P_TEMPLATE = os.path.join(os.path.dirname(__file__), "..", "apps", "outputs", "webapp_template")
|
||||
|
||||
|
||||
def p_auth() -> dict:
|
||||
from backend.auth import init_auth_token
|
||||
return {"Authorization": f"Bearer {init_auth_token()}"}
|
||||
|
||||
|
||||
class Blk:
|
||||
def __init__(self, type: str, text: str = "") -> None:
|
||||
self.type = type
|
||||
self.text = text
|
||||
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self, resp) -> None:
|
||||
self.resp = resp
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def get_final_message(self):
|
||||
return self.resp
|
||||
|
||||
|
||||
class FakeLLMClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
self.messages = self
|
||||
|
||||
def stream(self, **kw):
|
||||
self.calls.append(kw)
|
||||
resp = type("R", (), {"content": [Blk("text", "hello from fake")]})()
|
||||
return FakeStream(resp)
|
||||
|
||||
|
||||
def test_llm_routes_through_the_users_provider(monkeypatch):
|
||||
import backend.apps.agents.providers.registry as reg
|
||||
import backend.apps.settings.credentials as cred
|
||||
import backend.apps.settings.settings as settings_mod
|
||||
|
||||
fake = FakeLLMClient()
|
||||
monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True)
|
||||
|
||||
async def p_aux(settings, preferred_tier="haiku", primary_api=None):
|
||||
return ("aux-cheap", None)
|
||||
monkeypatch.setattr(reg, "resolve_aux_model", p_aux, raising=True)
|
||||
monkeypatch.setattr(cred, "get_anthropic_client_for_model", lambda s, m: fake, raising=True)
|
||||
|
||||
r = client.post("/api/apps-sdk/llm", json={"prompt": "say hello"}, headers=p_auth())
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"text": "hello from fake", "model": "aux-cheap"}
|
||||
assert fake.calls[0]["model"] == "aux-cheap"
|
||||
|
||||
|
||||
def test_llm_honors_an_explicit_model(monkeypatch):
|
||||
import backend.apps.agents.providers.registry as reg
|
||||
import backend.apps.settings.credentials as cred
|
||||
import backend.apps.settings.settings as settings_mod
|
||||
|
||||
fake = FakeLLMClient()
|
||||
monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True)
|
||||
monkeypatch.setattr(reg, "resolve_model_id_for_sdk", lambda short, s: f"resolved-{short}", raising=True)
|
||||
monkeypatch.setattr(cred, "get_anthropic_client_for_model", lambda s, m: fake, raising=True)
|
||||
|
||||
r = client.post("/api/apps-sdk/llm", json={"prompt": "hi", "model": "haiku", "system": "terse"},
|
||||
headers=p_auth())
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["model"] == "resolved-haiku"
|
||||
assert fake.calls[0]["system"] == "terse"
|
||||
|
||||
|
||||
def test_llm_rejects_an_empty_prompt():
|
||||
r = client.post("/api/apps-sdk/llm", json={"prompt": " "}, headers=p_auth())
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_spawn_agent_launches_and_broadcasts_position(monkeypatch):
|
||||
from backend.apps.agents import agent_manager as am_mod
|
||||
from backend.apps.agents.core import ws_manager as ws_mod
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
|
||||
launched = []
|
||||
messaged = []
|
||||
broadcasts = []
|
||||
|
||||
async def p_launch(config):
|
||||
launched.append(config)
|
||||
return AgentSession(id="sess-1", name=config.name, model=config.model, mode=config.mode)
|
||||
|
||||
async def p_send(session_id, prompt):
|
||||
messaged.append((session_id, prompt))
|
||||
|
||||
async def p_broadcast(event, payload):
|
||||
broadcasts.append((event, payload))
|
||||
|
||||
monkeypatch.setattr(am_mod.agent_manager, "launch_agent", p_launch, raising=True)
|
||||
monkeypatch.setattr(am_mod.agent_manager, "send_message", p_send, raising=True)
|
||||
monkeypatch.setattr(ws_mod.ws_manager, "broadcast_global", p_broadcast, raising=True)
|
||||
|
||||
r = client.post("/api/apps-sdk/agents/spawn", json={
|
||||
"prompt": "research crm tools", "name": "CRM scout", "x": 400, "y": 300,
|
||||
}, headers=p_auth())
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"session_id": "sess-1"}
|
||||
assert launched[0].name == "CRM scout" and launched[0].prompt == "research crm tools"
|
||||
assert broadcasts == [("apps_sdk:place_agent_card",
|
||||
{"session_id": "sess-1", "dashboard_id": None, "x": 400.0, "y": 300.0})]
|
||||
|
||||
|
||||
def test_spawn_agent_without_position_skips_the_broadcast(monkeypatch):
|
||||
from backend.apps.agents import agent_manager as am_mod
|
||||
from backend.apps.agents.core import ws_manager as ws_mod
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
|
||||
broadcasts = []
|
||||
|
||||
async def p_launch(config):
|
||||
return AgentSession(id="sess-2", name=config.name, model=config.model, mode=config.mode)
|
||||
|
||||
async def p_send(session_id, prompt):
|
||||
return None
|
||||
|
||||
async def p_broadcast(event, payload):
|
||||
broadcasts.append(event)
|
||||
|
||||
monkeypatch.setattr(am_mod.agent_manager, "launch_agent", p_launch, raising=True)
|
||||
monkeypatch.setattr(am_mod.agent_manager, "send_message", p_send, raising=True)
|
||||
monkeypatch.setattr(ws_mod.ws_manager, "broadcast_global", p_broadcast, raising=True)
|
||||
|
||||
r = client.post("/api/apps-sdk/agents/spawn", json={"prompt": "hi"}, headers=p_auth())
|
||||
assert r.status_code == 200
|
||||
assert broadcasts == []
|
||||
|
||||
|
||||
def test_template_ships_both_sdk_helpers_and_the_skill_references_them():
|
||||
front = os.path.join(P_TEMPLATE, "frontend", "src", "openswarmHost.ts")
|
||||
back = os.path.join(P_TEMPLATE, "backend", "apps", "openswarm_host", "openswarm_host.py")
|
||||
guide = os.path.join(P_TEMPLATE, "SDK.md")
|
||||
assert os.path.exists(front) and os.path.exists(back) and os.path.exists(guide)
|
||||
skill_path = os.path.join(P_TEMPLATE, "..", "app_builder_skill.md")
|
||||
with open(skill_path, "r", encoding="utf-8") as f:
|
||||
skill = f.read()
|
||||
assert "SDK.md" in skill and "openswarmHost" in skill and "openswarm_host" in skill
|
||||
# The tools/MCP surface is deliberately not wired yet; the guide must not advertise it as available.
|
||||
with open(guide, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
assert "does NOT give you" in text
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
|
||||
import { addBrowserCardFromBackend, setBrowserDocked, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { setCardPosition } from '../state/dashboardLayoutSlice';
|
||||
import { fetchSettings } from '../state/settingsSlice';
|
||||
import { displaySessionName } from '../state/sessionDisplay';
|
||||
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice';
|
||||
@@ -537,6 +538,26 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'apps_sdk:place_agent_card': {
|
||||
// An app asked for its spawned agent at a specific canvas spot; the card is created async
|
||||
// by the session lifecycle, so nudge it into place with a short bounded retry.
|
||||
const sid = data.session_id as string;
|
||||
const px = Number(data.x);
|
||||
const py = Number(data.y);
|
||||
if (!sid || !Number.isFinite(px) || !Number.isFinite(py)) break;
|
||||
let tries = 0;
|
||||
const place = () => {
|
||||
const exists = !!store.getState().dashboardLayout.cards[sid];
|
||||
if (exists) {
|
||||
store.dispatch(setCardPosition({ sessionId: sid, x: px, y: py }));
|
||||
return;
|
||||
}
|
||||
if (++tries < 20) setTimeout(place, 300);
|
||||
};
|
||||
place();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent:stream_start':
|
||||
case 'agent:stream_delta':
|
||||
case 'agent:stream_end':
|
||||
|
||||
Reference in New Issue
Block a user