Skip to content

Commit 19df9fc

Browse files
authored
Agent HTTP server (#284)
1 parent 736234f commit 19df9fc

25 files changed

Lines changed: 5408 additions & 47 deletions

File tree

agents-core/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ dependencies = [
2727
"numpy>=1.24.0",
2828
"mcp>=1.16.0",
2929
"colorlog>=6.10.1",
30+
"fastapi>=0.128.0",
31+
"uvicorn>=0.38.0",
3032
]
3133

3234
[project.urls]
Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
from vision_agents.core.edge.types import User
2-
31
from vision_agents.core.agents import Agent
4-
2+
from vision_agents.core.agents.agent_launcher import AgentLauncher, AgentSession
53
from vision_agents.core.cli.cli_runner import cli
6-
from vision_agents.core.agents.agent_launcher import AgentLauncher
4+
from vision_agents.core.edge.types import User
5+
from vision_agents.core.runner import Runner, ServeOptions
76

8-
__all__ = ["Agent", "User", "cli", "AgentLauncher"]
7+
__all__ = [
8+
"Agent",
9+
"User",
10+
"cli",
11+
"AgentLauncher",
12+
"AgentSession",
13+
"Runner",
14+
"ServeOptions",
15+
]

agents-core/vision_agents/core/agents/agent_launcher.py

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import asyncio
22
import logging
33
import weakref
4-
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
4+
from asyncio import Task
5+
from dataclasses import dataclass, field
6+
from datetime import datetime, timezone
7+
from typing import (
8+
TYPE_CHECKING,
9+
Any,
10+
AsyncIterator,
11+
Callable,
12+
Coroutine,
13+
Optional,
14+
cast,
15+
)
516

617
from vision_agents.core.utils.utils import await_or_run, cancel_and_wait
718
from vision_agents.core.warmup import Warmable, WarmupCache
@@ -12,6 +23,31 @@
1223
logger = logging.getLogger(__name__)
1324

1425

26+
@dataclass
27+
class AgentSession:
28+
agent: "Agent"
29+
call_id: str
30+
started_at: datetime
31+
task: asyncio.Task
32+
config: dict = field(default_factory=dict)
33+
created_by: Optional[Any] = None
34+
35+
@property
36+
def finished(self) -> bool:
37+
return self.task.done()
38+
39+
@property
40+
def id(self) -> str:
41+
return self.agent.id
42+
43+
async def wait(self):
44+
"""
45+
Wait for the session task to finish running.
46+
"""
47+
return await self.task
48+
49+
50+
# TODO: Rename to `AgentManager`.
1551
class AgentLauncher:
1652
"""
1753
Agent launcher that handles warmup and lifecycle management.
@@ -22,8 +58,8 @@ class AgentLauncher:
2258

2359
def __init__(
2460
self,
25-
create_agent: Callable[..., "Agent" | Awaitable["Agent"]],
26-
join_call: Callable[..., None | Awaitable[None]] | None = None,
61+
create_agent: Callable[..., "Agent" | Coroutine[Any, Any, "Agent"]],
62+
join_call: Callable[["Agent", str, str], Coroutine],
2763
agent_idle_timeout: float = 60.0,
2864
agent_idle_cleanup_interval: float = 5.0,
2965
):
@@ -37,8 +73,8 @@ def __init__(
3773
`0` means idle agents won't leave the call until it's ended.
3874
3975
"""
40-
self.create_agent = create_agent
41-
self.join_call = join_call
76+
self._create_agent = create_agent
77+
self._join_call = join_call
4278
self._warmup_lock = asyncio.Lock()
4379
self._warmup_cache = WarmupCache()
4480

@@ -55,6 +91,7 @@ def __init__(
5591
self._running = False
5692
self._cleanup_task: Optional[asyncio.Task] = None
5793
self._warmed_up: bool = False
94+
self._sessions: dict[str, AgentSession] = {}
5895

5996
async def start(self):
6097
if self._running:
@@ -70,6 +107,12 @@ async def stop(self):
70107
self._running = False
71108
if self._cleanup_task:
72109
await cancel_and_wait(self._cleanup_task)
110+
111+
coros = [cancel_and_wait(s.task) for s in self._sessions.values()]
112+
async for result in cast(AsyncIterator[Task], asyncio.as_completed(coros)):
113+
if result.done() and not result.cancelled() and result.exception():
114+
logger.error(f"Failed to cancel the agent task: {result.exception()}")
115+
73116
logger.debug("AgentLauncher stopped")
74117

75118
async def warmup(self) -> None:
@@ -86,13 +129,25 @@ async def warmup(self) -> None:
86129
logger.info("Creating agent...")
87130

88131
# Create a dry-run Agent instance and warmup its components for the first time.
89-
agent: "Agent" = await await_or_run(self.create_agent)
132+
agent: "Agent" = await await_or_run(self._create_agent)
90133
logger.info("Warming up agent components...")
91134
await self._warmup_agent(agent)
92135
self._warmed_up = True
93136

94137
logger.info("Agent warmup completed")
95138

139+
@property
140+
def warmed_up(self) -> bool:
141+
return self._warmed_up
142+
143+
@property
144+
def running(self) -> bool:
145+
return self._running
146+
147+
@property
148+
def ready(self) -> bool:
149+
return self.warmed_up and self.running
150+
96151
async def launch(self, **kwargs) -> "Agent":
97152
"""
98153
Launch the agent.
@@ -103,11 +158,70 @@ async def launch(self, **kwargs) -> "Agent":
103158
Returns:
104159
The Agent instance
105160
"""
106-
agent: "Agent" = await await_or_run(self.create_agent, **kwargs)
161+
agent: "Agent" = await await_or_run(self._create_agent, **kwargs)
107162
await self._warmup_agent(agent)
108163
self._active_agents.add(agent)
109164
return agent
110165

166+
async def start_session(
167+
self,
168+
call_id: str,
169+
call_type: str = "default",
170+
created_by: Optional[Any] = None,
171+
video_track_override_path: Optional[str] = None,
172+
) -> AgentSession:
173+
agent: "Agent" = await self.launch()
174+
if video_track_override_path:
175+
agent.set_video_track_override_path(video_track_override_path)
176+
177+
task = asyncio.create_task(
178+
self._join_call(agent, call_type, call_id), name=f"agent-{agent.id}"
179+
)
180+
181+
# Remove the session when the task is done
182+
def _done_cb(_, agent_id_=agent.id):
183+
self._sessions.pop(agent_id_, None)
184+
185+
task.add_done_callback(_done_cb)
186+
session = AgentSession(
187+
agent=agent,
188+
task=task,
189+
started_at=datetime.now(timezone.utc),
190+
call_id=call_id,
191+
created_by=created_by,
192+
)
193+
self._sessions[agent.id] = session
194+
logger.info(f"Start agent session with id {session.id}")
195+
return session
196+
197+
async def close_session(self, session_id: str, wait: bool = False) -> bool:
198+
"""
199+
Close session with id `session_id`.
200+
Returns `True` if session was found and closed, `False` otherwise.
201+
202+
Args:
203+
session_id: session id
204+
wait: when True, wait for the underlying agent to finish.
205+
Otherwise, just cancel the task and return.
206+
207+
Returns:
208+
`True` if session was found and closed, `False` otherwise.
209+
"""
210+
session = self._sessions.pop(session_id, None)
211+
if session is None:
212+
# The session is either closed or doesn't exist, exit early
213+
return False
214+
215+
logger.info(f"Closing agent session with id {session.id}")
216+
if wait:
217+
await cancel_and_wait(session.task)
218+
else:
219+
session.task.cancel()
220+
return True
221+
222+
def get_session(self, session_id: str) -> Optional[AgentSession]:
223+
return self._sessions.get(session_id)
224+
111225
async def _warmup_agent(self, agent: "Agent") -> None:
112226
"""
113227
Go over the Agent's dependencies and trigger `.warmup()` on them.

agents-core/vision_agents/core/agents/agents.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from ..llm.llm import LLM, AudioLLM, VideoLLM
5050
from ..llm.realtime import Realtime
5151
from ..mcp import MCPBaseServer, MCPManager
52+
from ..observability import MetricsCollector
5253
from ..observability.agent import AgentMetrics
5354
from ..processors.base_processor import (
5455
AudioProcessor,
@@ -148,6 +149,7 @@ def __init__(
148149
if not self.agent_user.id:
149150
self.agent_user.id = f"agent-{uuid4()}"
150151

152+
self._id = str(uuid4())
151153
self._pending_turn: Optional[LLMTurn] = None
152154
self.call: Optional[Call] = None
153155

@@ -254,6 +256,11 @@ def __init__(
254256
self._close_lock = asyncio.Lock()
255257
self._closed = False
256258
self._metrics = AgentMetrics()
259+
self._collector = MetricsCollector(self)
260+
261+
@property
262+
def id(self) -> str:
263+
return self._id
257264

258265
async def _finish_llm_turn(self):
259266
if self._pending_turn is None or self._pending_turn.response is None:

agents-core/vision_agents/core/cli/cli_runner.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,11 @@ async def _run():
121121
await agent.edge.open_demo_for_agent(agent, call_type, call_id)
122122

123123
# Join call if join_call function is provided
124-
if launcher.join_call:
125-
logger.info(f"📞 Joining call: {call_type}/{call_id}")
126-
result = launcher.join_call(agent, call_type, call_id)
127-
if asyncio.iscoroutine(result):
128-
await result
129-
else:
130-
logger.warning(
131-
'⚠️ No "join_call" function provided; the agent is created but will not join the call'
132-
)
124+
logger.info(f"📞 Joining call: {call_type}/{call_id}")
125+
session = await launcher.start_session(
126+
call_id, call_type, video_track_override_path=video_track_override
127+
)
128+
await session.wait()
133129
except KeyboardInterrupt:
134130
logger.info("🛑 Received interrupt signal, shutting down gracefully...")
135131
except Exception as e:

agents-core/vision_agents/core/observability/agent.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import abc
2+
import dataclasses
23
from dataclasses import dataclass, field
4+
from typing import Iterable
35

46

57
class _Metric(abc.ABC):
@@ -143,3 +145,25 @@ class AgentMetrics:
143145
video_processing_latency_ms__avg: Average = field(
144146
default_factory=lambda: Average("Average video frame processing latency")
145147
)
148+
149+
def to_dict(self, fields: Iterable[str] = ()) -> dict[str, int | float | None]:
150+
"""
151+
Convert metrics into a dictionary {<metric>: <value>}.
152+
153+
Args:
154+
fields: optional list of fields to extract. If empty, extract all fields.
155+
156+
Returns:
157+
a dictionary {<metric>: <value>}
158+
159+
"""
160+
all_fields = dataclasses.asdict(self)
161+
result = {}
162+
fields = fields or list(all_fields.keys())
163+
164+
for field_name in fields:
165+
field_ = all_fields.get(field_name)
166+
if field_ is None:
167+
raise ValueError(f"Unknown field: {field_name}")
168+
result[field_name] = field_.value()
169+
return result
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .http.options import ServeOptions as ServeOptions
2+
from .runner import Runner as Runner

agents-core/vision_agents/core/runner/http/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)