langstage-core 1.0.0__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.
@@ -0,0 +1,85 @@
1
+ """langstage-core — the host + AG-UI bridge for the LangStage agent family.
2
+
3
+ Since 1.0 (ADR 0003) the bespoke event-translation layer (``StreamParser``, the
4
+ ``events`` dataclasses, ``event_to_dict``, the render adapters, and the
5
+ ``stream_graph_updates`` helpers) is gone: surfaces stream through the in-process
6
+ AG-UI adapter instead. What remains is the *durable* core —
7
+
8
+ - **host**: ``load_agent_spec`` + ``HostConfig`` + ``Workspace`` (write once,
9
+ run on every surface with the same spec string and ``langstage.toml`` config).
10
+ - **agui**: ``build_agent`` + ``iter_event_frames`` / ``iter_chunk_frames``
11
+ (the in-process AG-UI stream, with an ``extractors=`` hook), ``build_app`` /
12
+ ``serve`` for the wire, and ``SessionAdapter`` for session-scoped streaming.
13
+ - **tasks**: the durable task-delegation engine.
14
+ - **extractors**: the ``ToolExtractor`` protocol + reusable built-ins.
15
+
16
+ Quick start::
17
+
18
+ from langstage_core.agui import build_agent, iter_event_frames
19
+ agent = build_agent(my_compiled_graph)
20
+ async for frame in iter_event_frames(agent, "hello", "session-1"):
21
+ ... # {"type": "content" | "tool_start" | "interrupt" | ...}
22
+ """
23
+ from importlib.metadata import PackageNotFoundError, version
24
+
25
+ from .extractors import (
26
+ CompressionExtractor,
27
+ DisplayInlineExtractor,
28
+ GenericToolExtractor,
29
+ MemoryExtractor,
30
+ SkillManageExtractor,
31
+ SkillViewExtractor,
32
+ ThinkToolExtractor,
33
+ TodoExtractor,
34
+ ToolExtractor,
35
+ )
36
+ from .host import HostConfig, Workspace, load_agent_spec
37
+ from .resume import create_resume_input, prepare_agent_input
38
+ from .tasks import (
39
+ InMemoryTaskStore,
40
+ TASK_TOOLS,
41
+ Task,
42
+ TaskRunner,
43
+ TaskState,
44
+ TaskStore,
45
+ current_task_id,
46
+ get_runner,
47
+ outcome_to_state,
48
+ set_runner,
49
+ )
50
+
51
+ try:
52
+ __version__ = version("langstage-core")
53
+ except PackageNotFoundError: # pragma: no cover - editable/source checkout
54
+ __version__ = "0.0.0+local"
55
+
56
+ __all__ = [
57
+ # Host conventions
58
+ "load_agent_spec",
59
+ "HostConfig",
60
+ "Workspace",
61
+ # Input helpers
62
+ "prepare_agent_input",
63
+ "create_resume_input",
64
+ # Extractors (ToolExtractor protocol + reusable built-ins)
65
+ "ToolExtractor",
66
+ "ThinkToolExtractor",
67
+ "TodoExtractor",
68
+ "DisplayInlineExtractor",
69
+ "GenericToolExtractor",
70
+ "SkillManageExtractor",
71
+ "SkillViewExtractor",
72
+ "MemoryExtractor",
73
+ "CompressionExtractor",
74
+ # Task-delegation engine
75
+ "TaskRunner",
76
+ "TaskStore",
77
+ "InMemoryTaskStore",
78
+ "Task",
79
+ "TaskState",
80
+ "TASK_TOOLS",
81
+ "set_runner",
82
+ "get_runner",
83
+ "current_task_id",
84
+ "outcome_to_state",
85
+ ]
@@ -0,0 +1,10 @@
1
+ """Session adapter for driving a LangGraph agent over the in-process AG-UI stream.
2
+
3
+ The bespoke render adapters (CLI/FastAPI/Jupyter/Print) and the ``StreamParser``
4
+ they wrapped were removed in langstage-core 1.0; ``SessionAdapter`` is now
5
+ AG-UI-only (see ADR 0003).
6
+ """
7
+
8
+ from .session import Session, SessionAdapter
9
+
10
+ __all__ = ["SessionAdapter", "Session"]
@@ -0,0 +1,309 @@
1
+ """Session-scoped streaming adapter for web hosts.
2
+
3
+ Where :class:`~langstage_core.adapters.fastapi.FastAPIAdapter` is
4
+ request-scoped (one turn per call), ``SessionAdapter`` keeps a long-lived
5
+ session that:
6
+
7
+ - survives client disconnects (a page refresh resumes the same session),
8
+ - multiplexes a per-session event queue so a producer turn and out-of-band
9
+ events (file-change notifications, etc.) interleave on one SSE stream,
10
+ - supports cancelling an in-flight turn,
11
+ - delegates conversation state to LangGraph's checkpointer (keyed by the
12
+ session id used as ``thread_id``).
13
+
14
+ This absorbs the ``session_manager`` + ``sse_adapter`` + chat-route plumbing
15
+ that ``cowork-dash`` used to carry in-tree.
16
+
17
+ Requires: ``pip install langstage-core[fastapi]`` for the SSE helper
18
+ (only the JSON framing needs nothing; ``asyncio`` is stdlib).
19
+ """
20
+ import asyncio
21
+ import json
22
+ import uuid
23
+ from datetime import datetime
24
+ from typing import Any, AsyncIterator
25
+
26
+ from ..resume import prepare_agent_input
27
+
28
+
29
+ class Session:
30
+ """State for a single chat session. Outlives any one transport connection."""
31
+
32
+ def __init__(self, session_id: str | None = None):
33
+ self.id: str = session_id or str(uuid.uuid4())
34
+ self.config: dict[str, Any] = {"configurable": {"thread_id": self.id}}
35
+ self.event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
36
+ self.current_task: asyncio.Task | None = None
37
+ self.sse_connected: bool = False
38
+ self.created_at: datetime = datetime.now()
39
+ # Typed terminal outcome of the most recent turn, set by ``_produce``.
40
+ # One of ``"complete" | "interrupted" | "error" | "cancelled"``, or
41
+ # ``None`` before the first turn finishes. Headless consumers (e.g. a
42
+ # task runner) read this instead of re-inspecting the event stream to
43
+ # tell whether a turn finished, paused on a HITL interrupt, or failed.
44
+ self.outcome: str | None = None
45
+ # When ``outcome == "interrupted"``, the serialized InterruptEvent
46
+ # (``{"type": "interrupt", "action_requests": [...], ...}``) that paused
47
+ # the turn — everything a consumer needs to render a review gate and
48
+ # build resume decisions. ``None`` otherwise.
49
+ self.interrupt: dict[str, Any] | None = None
50
+ # When ``outcome in {"error"}``, a human-readable error string.
51
+ self.error: str | None = None
52
+
53
+ def cancel_current(self) -> bool:
54
+ """Cancel the in-flight turn if any. Returns True if one was cancelled."""
55
+ if self.current_task is not None and not self.current_task.done():
56
+ self.current_task.cancel()
57
+ return True
58
+ return False
59
+
60
+ def push(self, event: dict[str, Any]) -> None:
61
+ """Enqueue a serialized event for SSE consumers (non-blocking)."""
62
+ # The queue is unbounded, so put_nowait never raises QueueFull and is
63
+ # safe to call from within an except/cancellation block.
64
+ self.event_queue.put_nowait(event)
65
+
66
+
67
+ class SessionAdapter:
68
+ """Session-scoped streaming for LangGraph agents.
69
+
70
+ Attributes:
71
+ graph: A compiled LangGraph graph (with a checkpointer for resumption).
72
+ stream_mode: Stream mode for ``astream`` and the parser. Defaults to
73
+ dual mode so content streams token-by-token while tool/interrupt
74
+ events arrive complete.
75
+ max_result_len: Max length for serialized tool results (see
76
+ :func:`event_to_dict`).
77
+ parser_kwargs: Extra kwargs forwarded to each ``StreamParser``
78
+ (e.g. ``skip_tools``, custom ``extractors`` via registration).
79
+
80
+ Example:
81
+ adapter = SessionAdapter(graph=agent)
82
+
83
+ # POST /api/chat → start a turn
84
+ adapter.submit_message(session_id, content, context_parts=[...])
85
+
86
+ # GET /api/stream → consume events (persistent EventSource)
87
+ return StreamingResponse(adapter.sse(session_id),
88
+ media_type="text/event-stream")
89
+
90
+ # POST /api/chat/interrupt → resume from a HITL interrupt
91
+ adapter.submit_decisions(session_id, decisions)
92
+
93
+ # POST /api/chat/cancel
94
+ adapter.cancel(session_id)
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ *,
100
+ graph: Any,
101
+ max_result_len: int = 500,
102
+ **_legacy: Any, # accepts + ignores the removed stream_mode/agui/parser kwargs
103
+ ):
104
+ self._graph = graph
105
+ self._max_result_len = max_result_len
106
+ self._sessions: dict[str, Session] = {}
107
+ # AG-UI-only since langstage-core 1.0 (ADR 0003): turns stream through the
108
+ # in-process AG-UI adapter (``agui.iter_event_frames``) — the wrapped agent
109
+ # is built lazily and reused (its checkpointer keys per-session by thread_id).
110
+ self._agui_agent: Any = None
111
+
112
+ # ── Session lifecycle ────────────────────────────────────────────
113
+
114
+ def get_or_create(self, session_id: str | None = None) -> Session:
115
+ """Resume an existing session by id, or create a fresh one.
116
+
117
+ A provided-but-unknown ``session_id`` is honored as the new session's
118
+ id, so a reconnecting client keeps its ``thread_id`` (and thus its
119
+ checkpointed history).
120
+ """
121
+ if session_id and session_id in self._sessions:
122
+ return self._sessions[session_id]
123
+ session = Session(session_id)
124
+ self._sessions[session.id] = session
125
+ return session
126
+
127
+ def get(self, session_id: str) -> Session | None:
128
+ """Look up a session by id, or None."""
129
+ return self._sessions.get(session_id)
130
+
131
+ def delete_session(self, session_id: str) -> bool:
132
+ """Cancel and remove a session. Returns True if it existed."""
133
+ session = self._sessions.pop(session_id, None)
134
+ if session is None:
135
+ return False
136
+ session.cancel_current()
137
+ return True
138
+
139
+ def list_sessions(self) -> list[dict[str, Any]]:
140
+ """Summaries of all live sessions."""
141
+ return [
142
+ {
143
+ "session_id": s.id,
144
+ "created_at": s.created_at.isoformat(),
145
+ "connected": s.sse_connected,
146
+ }
147
+ for s in self._sessions.values()
148
+ ]
149
+
150
+ @property
151
+ def active_count(self) -> int:
152
+ """Number of sessions with a connected SSE consumer."""
153
+ return sum(1 for s in self._sessions.values() if s.sse_connected)
154
+
155
+ # ── Producing turns ──────────────────────────────────────────────
156
+
157
+ def submit_message(
158
+ self,
159
+ session_id: str | None,
160
+ content: str,
161
+ *,
162
+ context_parts: list[str] | None = None,
163
+ ) -> Session:
164
+ """Start a turn for a user message. Cancels any in-flight turn first.
165
+
166
+ The turn runs as a background task that pushes serialized events onto
167
+ the session's queue; consume them via :meth:`sse`.
168
+ """
169
+ session = self.get_or_create(session_id)
170
+ session.cancel_current()
171
+ # Reuse prepare_agent_input's context-combining, then hand the raw text to
172
+ # the AG-UI producer (which builds its own RunAgentInput).
173
+ combined = prepare_agent_input(message=content, context_parts=context_parts)
174
+ text = combined["messages"][0]["content"]
175
+ session.current_task = asyncio.create_task(self._produce(session, message=text))
176
+ return session
177
+
178
+ def submit_decisions(
179
+ self,
180
+ session_id: str | None,
181
+ decisions: list[dict[str, Any]],
182
+ ) -> Session:
183
+ """Resume a session from an interrupt with HITL decisions."""
184
+ session = self.get_or_create(session_id)
185
+ session.cancel_current()
186
+ session.current_task = asyncio.create_task(
187
+ self._produce(session, resume={"decisions": decisions})
188
+ )
189
+ return session
190
+
191
+ def cancel(self, session_id: str) -> bool:
192
+ """Cancel the in-flight turn for a session. Returns True if one ran."""
193
+ session = self._sessions.get(session_id)
194
+ if session is None:
195
+ return False
196
+ return session.cancel_current()
197
+
198
+ def push_event(self, session_id: str | None, event: dict[str, Any]) -> Session:
199
+ """Push an out-of-band event onto a session's stream (side channel).
200
+
201
+ Use for events that don't originate from the agent — e.g. a file
202
+ watcher pushing ``{"type": "file_changed", ...}`` into the same SSE
203
+ stream the agent events flow through.
204
+ """
205
+ session = self.get_or_create(session_id)
206
+ session.push(event)
207
+ return session
208
+
209
+ async def _produce(
210
+ self,
211
+ session: Session,
212
+ *,
213
+ message: str = "",
214
+ resume: Any = None,
215
+ ) -> None:
216
+ """Run one turn through the in-process AG-UI adapter, pushing serialized
217
+ ``event_to_dict`` frames onto the session queue and recording the terminal
218
+ outcome (``session.outcome`` + ``session.interrupt`` / ``session.error``) so
219
+ headless consumers (the task runner) don't re-inspect the event stream.
220
+ """
221
+ from ..agui import build_agent, iter_event_frames
222
+
223
+ session.outcome = None
224
+ session.interrupt = None
225
+ session.error = None
226
+ pending_interrupt: dict[str, Any] | None = None
227
+ if self._agui_agent is None:
228
+ self._agui_agent = build_agent(self._graph)
229
+ # Clone per turn: the ag-ui-langgraph agent carries per-run state, so
230
+ # concurrent sessions (the task runner runs many at once) must not share
231
+ # one instance. clone() keeps the graph + checkpointer (thread state) but
232
+ # isolates the run — the same pattern build_app uses per request.
233
+ run_agent = self._agui_agent.clone()
234
+ thread_id = session.config.get("configurable", {}).get("thread_id", session.id)
235
+ try:
236
+ async for data in iter_event_frames(
237
+ run_agent,
238
+ message,
239
+ thread_id,
240
+ resume=resume,
241
+ max_result_len=self._max_result_len,
242
+ ):
243
+ session.push(data)
244
+ kind = data.get("type")
245
+ if kind == "interrupt":
246
+ pending_interrupt = data
247
+ elif kind == "error":
248
+ session.outcome = "error"
249
+ session.error = data.get("error")
250
+ return
251
+ elif kind == "complete":
252
+ if pending_interrupt is not None:
253
+ session.outcome = "interrupted"
254
+ session.interrupt = pending_interrupt
255
+ else:
256
+ session.outcome = "complete"
257
+ return
258
+ except asyncio.CancelledError:
259
+ session.outcome = "cancelled"
260
+ session.push({"type": "cancelled"})
261
+ raise
262
+ except Exception as exc: # noqa: BLE001 — surfaced to the client, not swallowed
263
+ session.outcome = "error"
264
+ session.error = f"{type(exc).__name__}: {exc}"
265
+ session.push({"type": "error", "error": f"{type(exc).__name__}: {exc}"})
266
+
267
+ # ── Consuming (SSE) ──────────────────────────────────────────────
268
+
269
+ async def sse(
270
+ self,
271
+ session_id: str | None = None,
272
+ *,
273
+ keepalive: float = 30.0,
274
+ send_init: bool = True,
275
+ ) -> AsyncIterator[str]:
276
+ """Yield Server-Sent Events for a session, persistently.
277
+
278
+ Drains the session queue, emitting one ``data: {json}\\n\\n`` frame per
279
+ event. On idle, emits a ``: keepalive`` comment every ``keepalive``
280
+ seconds to keep proxies from closing the connection. The generator runs
281
+ until the consumer stops iterating (e.g. the ASGI server detects the
282
+ client disconnected), so a single EventSource spans many turns.
283
+
284
+ Args:
285
+ session_id: Resume this session, or create one if None/unknown.
286
+ keepalive: Seconds between keepalive comments while idle.
287
+ send_init: Emit a ``session_init`` frame first so the client learns
288
+ its (possibly newly minted) session id for reconnects.
289
+ """
290
+ session = self.get_or_create(session_id)
291
+ session.sse_connected = True
292
+ try:
293
+ if send_init:
294
+ yield _sse_frame({"type": "session_init", "session_id": session.id})
295
+ while True:
296
+ try:
297
+ event = await asyncio.wait_for(
298
+ session.event_queue.get(), timeout=keepalive
299
+ )
300
+ yield _sse_frame(event)
301
+ except asyncio.TimeoutError:
302
+ yield ": keepalive\n\n"
303
+ finally:
304
+ session.sse_connected = False
305
+
306
+
307
+ def _sse_frame(event: dict[str, Any]) -> str:
308
+ """Format an event dict as a single SSE ``data:`` frame."""
309
+ return f"data: {json.dumps(event)}\n\n"