pyagentic-core 2.8.1.dev1__py3-none-any.whl → 2.9.1.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyagentic/api/_app.py CHANGED
@@ -29,7 +29,7 @@ from pydantic import BaseModel
29
29
  from pyagentic._base._agent._agent import BaseAgent
30
30
  from pyagentic.models.llm import LLMResponse
31
31
  from pyagentic.models.response import AgentResponse, ToolResponse
32
- from pyagentic.api._config import AgentsConfig, load_config
32
+ from pyagentic.api._config import AgentsConfig, JobsConfig, load_config
33
33
  from pyagentic.api._sessions import SessionManager
34
34
 
35
35
  # Agents may be passed as a single class, a list of classes, or a
@@ -79,16 +79,21 @@ def _validate_agent_class(cls: type) -> None:
79
79
  )
80
80
 
81
81
 
82
+ def _slugify(value: str) -> str:
83
+ """Convert a CamelCase or arbitrary name into a kebab-case URL/route slug."""
84
+ # Insert a hyphen at lower/digit -> upper boundaries (CamelCase -> kebab).
85
+ slug = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value).lower()
86
+ # Collapse any remaining non-alphanumeric runs into single hyphens.
87
+ slug = re.sub(r"[^a-z0-9]+", "-", slug).strip("-")
88
+ return slug or "agent"
89
+
90
+
82
91
  def _default_prefix(agent_class: type[BaseAgent]) -> str:
83
92
  """Derive a URL prefix from an agent class name (e.g. ResearchAgent -> /research)."""
84
93
  name = agent_class.__name__
85
94
  if name.endswith("Agent") and len(name) > len("Agent"):
86
95
  name = name[: -len("Agent")]
87
- # Insert a hyphen at lower/digit -> upper boundaries (CamelCase -> kebab).
88
- name = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", name).lower()
89
- # Collapse any remaining non-alphanumeric runs into single hyphens.
90
- name = re.sub(r"[^a-z0-9]+", "-", name).strip("-")
91
- return "/" + name
96
+ return "/" + _slugify(name)
92
97
 
93
98
 
94
99
  def _store_path_for(base: str, prefix: str, multi: bool) -> str:
@@ -135,33 +140,60 @@ def create_router(
135
140
  model: Optional[str] = None,
136
141
  name: Optional[str] = None,
137
142
  version: str = "0.1.0",
143
+ tags: Optional[list[str]] = None,
144
+ sessions: bool = True,
145
+ jobs: bool = False,
146
+ jobs_config: Optional["JobsConfig"] = None,
138
147
  ) -> APIRouter:
139
148
  """Build a FastAPI ``APIRouter`` exposing a single agent's endpoints.
140
149
 
141
- Mount it onto an existing FastAPI app::
150
+ Mount it onto an existing FastAPI app under whatever prefix you like::
142
151
 
143
- app.include_router(create_router(MyAgent), prefix="/agent")
152
+ app.include_router(create_router(MyAgent, name="my-agent"), prefix="/agents/my-agent")
144
153
 
145
154
  The router owns its own :class:`SessionManager`, exposed as
146
155
  ``router.sessions`` so callers can share it (e.g. with :func:`mount_mcp`).
156
+ When ``jobs`` is enabled, the job orchestrator is exposed as
157
+ ``router.orchestrator`` (otherwise ``None``) so callers can wire its
158
+ lifecycle into their app's lifespan.
159
+
160
+ Every route is given a unique ``name`` derived from ``name`` (e.g.
161
+ ``my-agent_chat``), so multiple agents can be mounted on one app without
162
+ colliding and individual routes remain addressable via ``url_path_for``.
147
163
 
148
164
  Routes registered (relative to the mount point):
149
165
  - ``GET /`` agent info, ``GET /health``, ``GET /schema``
150
- - ``POST/GET /sessions``, ``DELETE /sessions/{id}``
151
- - ``POST /sessions/{id}/chat``, ``POST /sessions/{id}/chat/stream``
152
- - ``GET /sessions/{id}/state``
166
+ - when ``sessions``: ``POST/GET /sessions``, ``DELETE /sessions/{id}``,
167
+ ``POST /sessions/{id}/chat``, ``POST /sessions/{id}/chat/stream``,
168
+ ``GET /sessions/{id}/state``
169
+ - when ``jobs``: ``POST/GET /jobs``, ``GET /jobs/{id}``,
170
+ ``GET /jobs/{id}/updates``, ``GET /jobs/{id}/stream``,
171
+ ``POST /jobs/{id}/cancel``
153
172
 
154
173
  Args:
155
174
  agent_class (type[BaseAgent]): The agent class to serve.
156
175
  model (Optional[str]): Default model for sessions. ``None`` falls back to
157
176
  the agent's own default.
158
- name (Optional[str]): Display name for the info route. Defaults to the
159
- class name.
177
+ name (Optional[str]): Display name for the info route and basis for
178
+ route names/tags. Defaults to the class name.
160
179
  version (str): Version string for the info route.
180
+ tags (Optional[list[str]]): OpenAPI tags applied to every route on the
181
+ router (groups them in the docs). ``None`` leaves routes untagged.
182
+ sessions (bool): If True, mount the session-based routes (create/list/
183
+ delete sessions plus synchronous chat, streaming chat, and state).
184
+ If False, those routes are omitted — interaction happens only via
185
+ ``jobs``.
186
+ jobs (bool): If True, mount a durable async job system at ``/jobs`` whose
187
+ orchestrator is exposed as ``router.orchestrator``. The caller is
188
+ responsible for starting/stopping that orchestrator (see
189
+ :func:`create_app`, which does this automatically).
190
+ jobs_config (Optional[JobsConfig]): Configuration for the job system when
191
+ ``jobs`` is True. Defaults to :class:`JobsConfig` defaults.
161
192
 
162
193
  Returns:
163
- APIRouter: A router with all agent routes registered, with its
164
- ``SessionManager`` attached as ``router.sessions``.
194
+ APIRouter: A router with the selected agent routes registered, with its
195
+ ``SessionManager`` attached as ``router.sessions`` and its job
196
+ orchestrator (or ``None``) as ``router.orchestrator``.
165
197
 
166
198
  Raises:
167
199
  TypeError: If agent_class is missing required metaclass attributes.
@@ -169,17 +201,18 @@ def create_router(
169
201
  _validate_agent_class(agent_class)
170
202
 
171
203
  name = name or agent_class.__name__
204
+ slug = _slugify(name)
172
205
  RequestModel = agent_class.__request_model__
173
206
  ResponseModel = agent_class.__response_model__
174
207
  StreamEventModel = agent_class.__stream_event_model__
175
208
  StateModel = agent_class.__state_class__
176
209
 
177
- router = APIRouter()
178
- sessions = SessionManager(agent_class, default_model=model)
210
+ router = APIRouter(tags=tags)
211
+ session_manager = SessionManager(agent_class, default_model=model)
179
212
 
180
213
  # ---- info routes ----
181
214
 
182
- @router.get("/")
215
+ @router.get("/", name=f"{slug}_info")
183
216
  async def agent_info() -> dict:
184
217
  """Return basic agent metadata."""
185
218
  return {
@@ -191,12 +224,12 @@ def create_router(
191
224
  "linked_agents": list(agent_class.__linked_agents__.keys()),
192
225
  }
193
226
 
194
- @router.get("/health")
227
+ @router.get("/health", name=f"{slug}_health")
195
228
  async def health() -> dict:
196
229
  """Liveness probe endpoint."""
197
230
  return {"status": "ok"}
198
231
 
199
- @router.get("/schema")
232
+ @router.get("/schema", name=f"{slug}_schema")
200
233
  async def schema() -> dict:
201
234
  """Return JSON schemas for request, response, stream event, and state models."""
202
235
  return {
@@ -206,9 +239,75 @@ def create_router(
206
239
  "state": StateModel.model_json_schema(),
207
240
  }
208
241
 
242
+ if sessions:
243
+ _register_session_routes(
244
+ router,
245
+ slug,
246
+ session_manager,
247
+ RequestModel,
248
+ ResponseModel,
249
+ StreamEventModel,
250
+ StateModel,
251
+ )
252
+
253
+ # Expose the session manager so callers can share it (e.g. with mount_mcp).
254
+ router.sessions = session_manager
255
+
256
+ orchestrator = None
257
+ if jobs:
258
+ orchestrator = _mount_jobs_router(
259
+ router,
260
+ agent_class,
261
+ session_manager,
262
+ jobs_config or JobsConfig(),
263
+ model,
264
+ tags,
265
+ )
266
+ router.orchestrator = orchestrator
267
+
268
+ return router
269
+
270
+
271
+ def _mount_jobs_router(
272
+ router: APIRouter,
273
+ agent_class: type[BaseAgent],
274
+ sessions: SessionManager,
275
+ jobs_config: "JobsConfig",
276
+ default_model: Optional[str],
277
+ tags: Optional[list[str]],
278
+ ) -> "JobOrchestrator":
279
+ """Build a job orchestrator and include its /jobs router; return the orchestrator."""
280
+ from pyagentic.api.jobs import JobOrchestrator, build_backend, build_store
281
+ from pyagentic.api.jobs._routes import build_jobs_router
282
+
283
+ store = build_store(jobs_config.store)
284
+ backend = build_backend(
285
+ agent_class,
286
+ sessions,
287
+ max_concurrency=jobs_config.max_concurrency,
288
+ default_model=default_model,
289
+ )
290
+ orchestrator = JobOrchestrator(store, backend, jobs_config, sessions)
291
+ router.include_router(
292
+ build_jobs_router(orchestrator, agent_class, sessions), tags=tags
293
+ )
294
+ return orchestrator
295
+
296
+
297
+ def _register_session_routes(
298
+ router: APIRouter,
299
+ slug: str,
300
+ sessions: SessionManager,
301
+ RequestModel: type,
302
+ ResponseModel: type,
303
+ StreamEventModel: type,
304
+ StateModel: type,
305
+ ) -> None:
306
+ """Register the session-based routes (sessions CRUD, chat, stream, state)."""
307
+
209
308
  # ---- session routes ----
210
309
 
211
- @router.post("/sessions", status_code=201)
310
+ @router.post("/sessions", status_code=201, name=f"{slug}_create_session")
212
311
  async def create_session(req: Optional[CreateSessionRequest] = None) -> dict:
213
312
  """Create a new agent session."""
214
313
  model = req.model if req else None
@@ -216,12 +315,12 @@ def create_router(
216
315
  session_id = sessions.create(model=model, api_key=api_key)
217
316
  return {"session_id": session_id}
218
317
 
219
- @router.get("/sessions")
318
+ @router.get("/sessions", name=f"{slug}_list_sessions")
220
319
  async def list_sessions() -> dict:
221
320
  """List all active session IDs."""
222
321
  return {"sessions": sessions.list_sessions()}
223
322
 
224
- @router.delete("/sessions/{session_id}")
323
+ @router.delete("/sessions/{session_id}", name=f"{slug}_delete_session")
225
324
  async def delete_session(session_id: str) -> dict:
226
325
  """Delete an existing session."""
227
326
  try:
@@ -232,7 +331,11 @@ def create_router(
232
331
 
233
332
  # ---- chat routes ----
234
333
 
235
- @router.post("/sessions/{session_id}/chat", response_model=ResponseModel)
334
+ @router.post(
335
+ "/sessions/{session_id}/chat",
336
+ response_model=ResponseModel,
337
+ name=f"{slug}_chat",
338
+ )
236
339
  async def chat(session_id: str, req: RequestModel): # type: ignore[valid-type]
237
340
  """Send a message and receive a complete agent response."""
238
341
  try:
@@ -247,6 +350,7 @@ def create_router(
247
350
  @router.post(
248
351
  "/sessions/{session_id}/chat/stream",
249
352
  response_model=StreamEventModel,
353
+ name=f"{slug}_chat_stream",
250
354
  responses={
251
355
  200: {
252
356
  "description": (
@@ -307,7 +411,11 @@ def create_router(
307
411
 
308
412
  # ---- state route ----
309
413
 
310
- @router.get("/sessions/{session_id}/state", response_model=StateModel)
414
+ @router.get(
415
+ "/sessions/{session_id}/state",
416
+ response_model=StateModel,
417
+ name=f"{slug}_get_state",
418
+ )
311
419
  async def get_state(session_id: str):
312
420
  """Return the current state of the agent for a session."""
313
421
  try:
@@ -316,11 +424,6 @@ def create_router(
316
424
  raise HTTPException(status_code=404, detail="Session not found")
317
425
  return agent.state
318
426
 
319
- # Expose the session manager so callers can share it (e.g. with mount_mcp).
320
- router.sessions = sessions
321
-
322
- return router
323
-
324
427
 
325
428
  def _wire_jobs_lifespan(app: FastAPI, orchestrators: list) -> None:
326
429
  """Start/stop the per-agent job orchestrators inside the app's lifespan.
@@ -360,6 +463,7 @@ def create_app(
360
463
  description: Optional[str] = None,
361
464
  model: Optional[str] = None,
362
465
  mcp: bool = False,
466
+ sessions: bool = True,
363
467
  jobs: bool = False,
364
468
  ) -> FastAPI:
365
469
  """Build a standalone FastAPI app serving one or several agents.
@@ -388,6 +492,10 @@ def create_app(
388
492
  model (Optional[str]): Default model for all agents' sessions. Overrides
389
493
  config.
390
494
  mcp (bool): If True, mount an MCP endpoint per agent at ``<prefix>/mcp``.
495
+ sessions (bool): If True (default), mount the session-based routes
496
+ (sessions CRUD plus synchronous/streaming chat and state) for each
497
+ agent. If False, those routes are omitted — pair with ``jobs`` to
498
+ serve agents purely through the async job API.
391
499
  jobs (bool): If True (or ``[jobs].enabled`` in config), mount a durable
392
500
  async job system per agent at ``<prefix>/jobs`` — runs submitted there
393
501
  execute in the background and survive client timeouts/reconnects.
@@ -420,7 +528,24 @@ def create_app(
420
528
  index = []
421
529
  orchestrators = []
422
530
  for prefix, agent_class in mapping.items():
423
- router = create_router(agent_class, model=model, name=name, version=version)
531
+ # The root agent inherits the app's display name; agents under a prefix
532
+ # take a name derived from that prefix, so each router's route names stay
533
+ # unique across the app.
534
+ agent_name = name if prefix == "" else prefix.strip("/").replace("/", "-")
535
+ jobs_cfg = None
536
+ if jobs_enabled:
537
+ jobs_cfg = config.jobs.model_copy(
538
+ update={"store": _store_path_for(config.jobs.store, prefix, multi)}
539
+ )
540
+ router = create_router(
541
+ agent_class,
542
+ model=model,
543
+ name=agent_name,
544
+ version=version,
545
+ sessions=sessions,
546
+ jobs=jobs_enabled,
547
+ jobs_config=jobs_cfg,
548
+ )
424
549
  app.include_router(router, prefix=prefix)
425
550
  index.append({"agent_class": agent_class.__name__, "prefix": prefix or "/"})
426
551
  if mcp:
@@ -432,23 +557,8 @@ def create_app(
432
557
  sessions=router.sessions,
433
558
  path=(prefix + "/mcp"),
434
559
  )
435
- if jobs_enabled:
436
- from pyagentic.api.jobs import JobOrchestrator, build_backend, build_store
437
- from pyagentic.api.jobs._routes import build_jobs_router
438
-
439
- store = build_store(_store_path_for(config.jobs.store, prefix, multi))
440
- backend = build_backend(
441
- agent_class,
442
- router.sessions,
443
- max_concurrency=config.jobs.max_concurrency,
444
- default_model=model,
445
- )
446
- orchestrator = JobOrchestrator(store, backend, config.jobs, router.sessions)
447
- app.include_router(
448
- build_jobs_router(orchestrator, agent_class, router.sessions),
449
- prefix=prefix,
450
- )
451
- orchestrators.append(orchestrator)
560
+ if router.orchestrator is not None:
561
+ orchestrators.append(router.orchestrator)
452
562
 
453
563
  if orchestrators:
454
564
  _wire_jobs_lifespan(app, orchestrators)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyagentic-core
3
- Version: 2.8.1.dev1
3
+ Version: 2.9.1.dev1
4
4
  Summary: Build LLM Agents in a Pythonic way
5
5
  Author-email: Ryan Mikulec <rmikulec.dev@gmail.com>
6
6
  License: MIT
@@ -19,7 +19,7 @@ pyagentic/_base/_agent/_agent_state.py,sha256=gvx0rQ1gaWfZHHAONX4YNsO_HE0xYQ14_G
19
19
  pyagentic/_utils/_typing.py,sha256=5aRZZOVzR0ZgnBfejkw__1yoARhn70P5QzA81sxUkls,3681
20
20
  pyagentic/_utils/_warnings.py,sha256=mjlBiYHGnnh5599Gsq5ke8mBFS6WsdhNa7jk20TT9_U,1330
21
21
  pyagentic/api/__init__.py,sha256=JU5AwzXEAEX8RmQSaatYYMIh7DWx-8edjMapUIb9jJg,1111
22
- pyagentic/api/_app.py,sha256=NIPjxR95dxml-g6xktvxsZgH7-DSmNeb7ctEjm-7028,17511
22
+ pyagentic/api/_app.py,sha256=SxZ7lbIZqxdmKsbitEy2njfYjbr0MaMFkmy_PkaNzP8,21765
23
23
  pyagentic/api/_config.py,sha256=2YaFBDW7aaRueemHPBd1XzW9m0oqVCBilt8MA-x6QqI,6547
24
24
  pyagentic/api/_docker.py,sha256=AoAQ66gT-Uf5WhiYpImKSpTPyboiTaqWvxG7mBLe54I,2229
25
25
  pyagentic/api/_mcp_server.py,sha256=9aOC18-FSv1qS7umqFHxMKV5FujblziR-_NDfNsXmFo,5032
@@ -51,8 +51,8 @@ pyagentic/tracing/__init__.py,sha256=ddMOl_-Nf3WOhu-PYxSJ3kqHZ3eQXAOM5aySn9YmDtA
51
51
  pyagentic/tracing/_basic.py,sha256=jUBKdkOPveCjBzEhHKS_Rvp5l38iZg-5nFIl6xEwOVk,8906
52
52
  pyagentic/tracing/_langfuse.py,sha256=5b5tdT-gyRJP5HDcvCS4tn_k3AFDI6eNsXOd6On5gKQ,13986
53
53
  pyagentic/tracing/_tracer.py,sha256=HMisWfRLAQ9sdcjOvImiLij6OASd3siM8K-7nUfM3xs,7656
54
- pyagentic_core-2.8.1.dev1.dist-info/licenses/LICENSE,sha256=wcOzTj82hOc96HztJk2VzLB2hFHpdIGpjz8vvbpP1_s,1069
55
- pyagentic_core-2.8.1.dev1.dist-info/METADATA,sha256=FXRlE2GAzNbCgji41geVpTdaYp18n42M8YJoDns8vxk,8211
56
- pyagentic_core-2.8.1.dev1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
57
- pyagentic_core-2.8.1.dev1.dist-info/top_level.txt,sha256=lAWfd-ay434uEpSg2FM0ySN0o4vgNGE6D9dJkIhGyfY,10
58
- pyagentic_core-2.8.1.dev1.dist-info/RECORD,,
54
+ pyagentic_core-2.9.1.dev1.dist-info/licenses/LICENSE,sha256=wcOzTj82hOc96HztJk2VzLB2hFHpdIGpjz8vvbpP1_s,1069
55
+ pyagentic_core-2.9.1.dev1.dist-info/METADATA,sha256=ay00-v9nTLi1IfhPsVYxt3RrdxOLm_maoWM6C-U_5R0,8211
56
+ pyagentic_core-2.9.1.dev1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
57
+ pyagentic_core-2.9.1.dev1.dist-info/top_level.txt,sha256=lAWfd-ay434uEpSg2FM0ySN0o4vgNGE6D9dJkIhGyfY,10
58
+ pyagentic_core-2.9.1.dev1.dist-info/RECORD,,