kaboo-workflows 0.9.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.
- kaboo_workflows/__init__.py +66 -0
- kaboo_workflows/_context.py +135 -0
- kaboo_workflows/adapters/__init__.py +7 -0
- kaboo_workflows/adapters/_activity.py +172 -0
- kaboo_workflows/adapters/_multiagent.py +387 -0
- kaboo_workflows/adapters/_strands_bridge.py +107 -0
- kaboo_workflows/adapters/agui.py +958 -0
- kaboo_workflows/cli.py +434 -0
- kaboo_workflows/config/__init__.py +53 -0
- kaboo_workflows/config/interpolation.py +196 -0
- kaboo_workflows/config/loaders/__init__.py +12 -0
- kaboo_workflows/config/loaders/helpers.py +409 -0
- kaboo_workflows/config/loaders/loaders.py +292 -0
- kaboo_workflows/config/loaders/validators.py +124 -0
- kaboo_workflows/config/resolvers/__init__.py +28 -0
- kaboo_workflows/config/resolvers/agents.py +255 -0
- kaboo_workflows/config/resolvers/config.py +466 -0
- kaboo_workflows/config/resolvers/conversation_manager.py +54 -0
- kaboo_workflows/config/resolvers/hooks.py +68 -0
- kaboo_workflows/config/resolvers/mcp.py +106 -0
- kaboo_workflows/config/resolvers/models.py +47 -0
- kaboo_workflows/config/resolvers/orchestrations/__init__.py +81 -0
- kaboo_workflows/config/resolvers/orchestrations/builders.py +409 -0
- kaboo_workflows/config/resolvers/orchestrations/planner.py +103 -0
- kaboo_workflows/config/resolvers/schema_prompt.py +67 -0
- kaboo_workflows/config/resolvers/session_manager.py +183 -0
- kaboo_workflows/config/schema.py +457 -0
- kaboo_workflows/converters/__init__.py +13 -0
- kaboo_workflows/converters/base.py +54 -0
- kaboo_workflows/converters/openai.py +381 -0
- kaboo_workflows/converters/raw.py +31 -0
- kaboo_workflows/exceptions.py +27 -0
- kaboo_workflows/hooks/__init__.py +21 -0
- kaboo_workflows/hooks/event_publisher.py +600 -0
- kaboo_workflows/hooks/history_hook.py +145 -0
- kaboo_workflows/hooks/interrupt_hook.py +52 -0
- kaboo_workflows/hooks/max_calls_guard.py +90 -0
- kaboo_workflows/hooks/stop_guard.py +113 -0
- kaboo_workflows/hooks/tool_name_sanitizer.py +184 -0
- kaboo_workflows/manifest.py +306 -0
- kaboo_workflows/mcp/README.md +105 -0
- kaboo_workflows/mcp/__init__.py +27 -0
- kaboo_workflows/mcp/client.py +154 -0
- kaboo_workflows/mcp/lifecycle.py +246 -0
- kaboo_workflows/mcp/server.py +327 -0
- kaboo_workflows/mcp/transports.py +188 -0
- kaboo_workflows/models.py +69 -0
- kaboo_workflows/py.typed +0 -0
- kaboo_workflows/renderers/__init__.py +9 -0
- kaboo_workflows/renderers/ansi.py +295 -0
- kaboo_workflows/renderers/base.py +36 -0
- kaboo_workflows/serve.py +64 -0
- kaboo_workflows/startup/__init__.py +24 -0
- kaboo_workflows/startup/report.py +174 -0
- kaboo_workflows/startup/validator.py +167 -0
- kaboo_workflows/tools/__init__.py +38 -0
- kaboo_workflows/tools/ask_user.py +76 -0
- kaboo_workflows/tools/extractors.py +154 -0
- kaboo_workflows/tools/loaders.py +259 -0
- kaboo_workflows/tools/wrappers.py +158 -0
- kaboo_workflows/types.py +321 -0
- kaboo_workflows/utils.py +209 -0
- kaboo_workflows/wire.py +317 -0
- kaboo_workflows-0.9.0.dist-info/METADATA +364 -0
- kaboo_workflows-0.9.0.dist-info/RECORD +68 -0
- kaboo_workflows-0.9.0.dist-info/WHEEL +4 -0
- kaboo_workflows-0.9.0.dist-info/entry_points.txt +3 -0
- kaboo_workflows-0.9.0.dist-info/licenses/LICENSE +174 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""kaboo-workflows — YAML-driven multi-agent orchestration with AG-UI/CopilotKit support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .config import (
|
|
6
|
+
AppConfig,
|
|
7
|
+
ConfigInput,
|
|
8
|
+
ResolvedConfig,
|
|
9
|
+
ResolvedInfra,
|
|
10
|
+
load,
|
|
11
|
+
load_config,
|
|
12
|
+
load_session,
|
|
13
|
+
resolve_infra,
|
|
14
|
+
)
|
|
15
|
+
from .config.resolvers.orchestrations import OrchestrationBuilder
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
CircularDependencyError,
|
|
18
|
+
ConfigurationError,
|
|
19
|
+
ImportResolutionError,
|
|
20
|
+
SchemaValidationError,
|
|
21
|
+
UnresolvedReferenceError,
|
|
22
|
+
)
|
|
23
|
+
from .hooks import EventPublisher, MaxToolCallsGuard, StopGuard, ToolNameSanitizer
|
|
24
|
+
from .mcp import MCPLifecycle, create_mcp_client, create_mcp_server
|
|
25
|
+
from .renderers import AnsiRenderer
|
|
26
|
+
from .tools import (
|
|
27
|
+
node_as_async_tool,
|
|
28
|
+
node_as_tool,
|
|
29
|
+
serialize_multiagent_result,
|
|
30
|
+
)
|
|
31
|
+
from .types import EventType, StreamEvent
|
|
32
|
+
from .utils import cli_errors
|
|
33
|
+
from .wire import EventQueue, make_event_queue
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AnsiRenderer",
|
|
37
|
+
"AppConfig",
|
|
38
|
+
"CircularDependencyError",
|
|
39
|
+
"ConfigInput",
|
|
40
|
+
"ConfigurationError",
|
|
41
|
+
"EventPublisher",
|
|
42
|
+
"EventQueue",
|
|
43
|
+
"EventType",
|
|
44
|
+
"ImportResolutionError",
|
|
45
|
+
"MCPLifecycle",
|
|
46
|
+
"MaxToolCallsGuard",
|
|
47
|
+
"OrchestrationBuilder",
|
|
48
|
+
"ResolvedConfig",
|
|
49
|
+
"ResolvedInfra",
|
|
50
|
+
"SchemaValidationError",
|
|
51
|
+
"StopGuard",
|
|
52
|
+
"StreamEvent",
|
|
53
|
+
"ToolNameSanitizer",
|
|
54
|
+
"UnresolvedReferenceError",
|
|
55
|
+
"cli_errors",
|
|
56
|
+
"create_mcp_client",
|
|
57
|
+
"create_mcp_server",
|
|
58
|
+
"load",
|
|
59
|
+
"load_config",
|
|
60
|
+
"load_session",
|
|
61
|
+
"make_event_queue",
|
|
62
|
+
"node_as_async_tool",
|
|
63
|
+
"node_as_tool",
|
|
64
|
+
"resolve_infra",
|
|
65
|
+
"serialize_multiagent_result",
|
|
66
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Per-conversation activity context.
|
|
2
|
+
|
|
3
|
+
Carries the current ``thread_id`` / ``run_id`` through the async call stack so
|
|
4
|
+
that :class:`~kaboo_workflows.hooks.EventPublisher` can stamp every
|
|
5
|
+
:class:`~kaboo_workflows.types.StreamEvent` with the conversation it belongs to.
|
|
6
|
+
|
|
7
|
+
This is what allows a single server process to serve multiple concurrent
|
|
8
|
+
conversations without their activity streams clobbering one another. On
|
|
9
|
+
AgentCore (one microVM per session) it is redundant but harmless; on a plain
|
|
10
|
+
self-hosted process it is what makes per-conversation routing correct.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import contextvars
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
_current_thread_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
22
|
+
"kaboo_thread_id", default=None
|
|
23
|
+
)
|
|
24
|
+
_current_run_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
25
|
+
"kaboo_run_id", default=None
|
|
26
|
+
)
|
|
27
|
+
_current_turn_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
28
|
+
"kaboo_turn_id", default=None
|
|
29
|
+
)
|
|
30
|
+
_current_delegation_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
31
|
+
"kaboo_delegation_id", default=None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class HistoryExchange:
|
|
37
|
+
"""Request-scoped carrier for client-driven, per-agent history.
|
|
38
|
+
|
|
39
|
+
``inbound`` holds the transcripts the client sent this run (parsed from
|
|
40
|
+
``RunAgentInput.state['kaboo_history']``), keyed per agent bucket.
|
|
41
|
+
``outbound`` accumulates the transcripts agents produce this run; it is
|
|
42
|
+
merged back into the outgoing ``STATE_SNAPSHOT`` so the client persists
|
|
43
|
+
and replays them next turn.
|
|
44
|
+
|
|
45
|
+
One instance is created per request and shared by reference across the
|
|
46
|
+
request's tasks via :data:`_current_history`, so a sub-agent hook running
|
|
47
|
+
in a child task and the snapshot enricher running in the parent see the
|
|
48
|
+
same buffers.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
inbound: dict[str, list[Any]] = field(default_factory=dict)
|
|
52
|
+
outbound: dict[str, list[Any]] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_current_history: contextvars.ContextVar[HistoryExchange | None] = contextvars.ContextVar(
|
|
56
|
+
"kaboo_history_exchange", default=None
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_activity_context(
|
|
61
|
+
thread_id: str | None,
|
|
62
|
+
run_id: str | None = None,
|
|
63
|
+
turn_id: str | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Bind the current conversation's ``thread_id`` / ``run_id`` / ``turn_id``.
|
|
66
|
+
|
|
67
|
+
Call at the start of each request handler. Tasks created afterwards
|
|
68
|
+
(via ``asyncio.create_task``) inherit a copy of this context, so nested
|
|
69
|
+
agent/delegate activity is attributed to the right conversation.
|
|
70
|
+
|
|
71
|
+
``turn_id`` identifies one logical user turn and is **stable across an
|
|
72
|
+
interrupt/resume** (unlike ``run_id``, which changes on the resume POST).
|
|
73
|
+
It lets the UI bind every group a turn produced — even ones that first
|
|
74
|
+
appear only after a resume — to that turn's single chat reply.
|
|
75
|
+
"""
|
|
76
|
+
_current_thread_id.set(thread_id)
|
|
77
|
+
_current_run_id.set(run_id)
|
|
78
|
+
_current_turn_id.set(turn_id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_thread_id() -> str | None:
|
|
82
|
+
"""Return the current conversation's ``thread_id`` (or ``None``)."""
|
|
83
|
+
return _current_thread_id.get()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_run_id() -> str | None:
|
|
87
|
+
"""Return the current conversation's ``run_id`` (or ``None``)."""
|
|
88
|
+
return _current_run_id.get()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_turn_id() -> str | None:
|
|
92
|
+
"""Return the current logical turn's id (stable across resume, or ``None``)."""
|
|
93
|
+
return _current_turn_id.get()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@contextlib.contextmanager
|
|
97
|
+
def delegation_context(tool_use_id: str | None) -> Iterator[None]:
|
|
98
|
+
"""Bind the delegating tool-call id for the wrapped node invocation.
|
|
99
|
+
|
|
100
|
+
A coordinator delegates to a sub-agent by calling a tool; that tool call has
|
|
101
|
+
a ``toolUseId`` which is the same id the frontend (AG-UI / CopilotKit) sees
|
|
102
|
+
for the inline card. Binding it here lets
|
|
103
|
+
:class:`~kaboo_workflows.hooks.EventPublisher` stamp the sub-agent's stream
|
|
104
|
+
group with that id, so the card can be correlated to its activity group by a
|
|
105
|
+
stable key instead of by arrival order.
|
|
106
|
+
|
|
107
|
+
Nesting is token-based: a sub-agent that itself delegates temporarily
|
|
108
|
+
overrides the id and restores the parent's on exit.
|
|
109
|
+
"""
|
|
110
|
+
token = _current_delegation_id.set(tool_use_id)
|
|
111
|
+
try:
|
|
112
|
+
yield
|
|
113
|
+
finally:
|
|
114
|
+
_current_delegation_id.reset(token)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_delegation_id() -> str | None:
|
|
118
|
+
"""Return the current delegating tool-call id (or ``None``)."""
|
|
119
|
+
return _current_delegation_id.get()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def set_history_exchange(exchange: HistoryExchange | None) -> None:
|
|
123
|
+
"""Bind the current request's :class:`HistoryExchange`.
|
|
124
|
+
|
|
125
|
+
Call at the start of each request handler, before creating the run task.
|
|
126
|
+
Tasks created afterwards inherit a copy of this context that references
|
|
127
|
+
the same exchange object, so hook writes made in child tasks are visible
|
|
128
|
+
to the parent snapshot enricher.
|
|
129
|
+
"""
|
|
130
|
+
_current_history.set(exchange)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_history_exchange() -> HistoryExchange | None:
|
|
134
|
+
"""Return the current request's :class:`HistoryExchange` (or ``None``)."""
|
|
135
|
+
return _current_history.get()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Per-conversation activity state builder.
|
|
2
|
+
|
|
3
|
+
Folds kaboo ``StreamEvent``s into a hierarchical activity snapshot
|
|
4
|
+
(``{groups: {...}}``) keyed by ``thread_id``. The snapshot is emitted onto the
|
|
5
|
+
AG-UI run stream as ``ACTIVITY_SNAPSHOT`` events (see ``agui.py``), so there is
|
|
6
|
+
no separate SSE endpoint or subscriber machinery here — this is state only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from kaboo_workflows.types import EventType, StreamEvent
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _update_activity_state(state: dict[str, Any], event: StreamEvent) -> bool:
|
|
22
|
+
"""Mutate *state* based on an incoming kaboo StreamEvent.
|
|
23
|
+
|
|
24
|
+
Maintains a ``timeline`` list that preserves the chronological order of
|
|
25
|
+
text segments and tool calls as they arrive. Adjacent TOKEN events are
|
|
26
|
+
coalesced into a single text entry.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
``True`` if the state changed (and a fresh snapshot should be emitted),
|
|
30
|
+
``False`` for events that carry no stream group (nothing to render).
|
|
31
|
+
"""
|
|
32
|
+
group = event.data.get("stream_group", "")
|
|
33
|
+
if not group:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
groups = state["groups"]
|
|
37
|
+
|
|
38
|
+
if event.type == EventType.STREAM_GROUP_START:
|
|
39
|
+
existing = groups.get(group)
|
|
40
|
+
if existing is not None:
|
|
41
|
+
existing["status"] = "active"
|
|
42
|
+
existing.pop("interrupt", None)
|
|
43
|
+
else:
|
|
44
|
+
groups[group] = {
|
|
45
|
+
"title": event.data.get("stream_title", ""),
|
|
46
|
+
"agentName": event.data.get("agent_name", ""),
|
|
47
|
+
"parentGroup": event.data.get("parent_group"),
|
|
48
|
+
"toolCallId": event.data.get("tool_call_id"),
|
|
49
|
+
"runId": event.data.get("run_id"),
|
|
50
|
+
"turnId": event.data.get("turn_id"),
|
|
51
|
+
"task": event.data.get("task"),
|
|
52
|
+
"isChatReply": bool(event.data.get("is_chat_reply", False)),
|
|
53
|
+
"inlineChatOwner": bool(event.data.get("inline_chat_owner", False)),
|
|
54
|
+
"status": "active",
|
|
55
|
+
"tools": [],
|
|
56
|
+
"tokens": "",
|
|
57
|
+
"timeline": [],
|
|
58
|
+
}
|
|
59
|
+
logger.info(
|
|
60
|
+
"activity.STREAM_GROUP_START group=%s agent=%s parent=%s "
|
|
61
|
+
"tool_call_id=%s run_id=%s is_chat_reply=%s task=%r thread_id=%s",
|
|
62
|
+
group,
|
|
63
|
+
event.data.get("agent_name"),
|
|
64
|
+
event.data.get("parent_group"),
|
|
65
|
+
event.data.get("tool_call_id"),
|
|
66
|
+
event.data.get("run_id"),
|
|
67
|
+
event.data.get("is_chat_reply"),
|
|
68
|
+
event.data.get("task"),
|
|
69
|
+
event.data.get("thread_id"),
|
|
70
|
+
)
|
|
71
|
+
elif event.type == EventType.AGENT_START:
|
|
72
|
+
if group in groups:
|
|
73
|
+
groups[group]["status"] = "active"
|
|
74
|
+
groups[group].pop("interrupt", None)
|
|
75
|
+
elif event.type == EventType.STREAM_GROUP_END:
|
|
76
|
+
if group in groups:
|
|
77
|
+
groups[group]["status"] = event.data.get("status", "completed")
|
|
78
|
+
elif event.type == EventType.TOOL_START:
|
|
79
|
+
if group in groups:
|
|
80
|
+
tool_use_id = event.data.get("tool_use_id", "")
|
|
81
|
+
existing_tool = next(
|
|
82
|
+
(t for t in groups[group]["tools"] if t["toolUseId"] == tool_use_id),
|
|
83
|
+
None,
|
|
84
|
+
)
|
|
85
|
+
if existing_tool is not None:
|
|
86
|
+
existing_tool["status"] = "running"
|
|
87
|
+
else:
|
|
88
|
+
tool_entry = {
|
|
89
|
+
"toolUseId": tool_use_id,
|
|
90
|
+
"toolName": event.data.get("tool_name", ""),
|
|
91
|
+
"toolLabel": event.data.get("tool_label", ""),
|
|
92
|
+
"toolInput": event.data.get("tool_input"),
|
|
93
|
+
"status": "running",
|
|
94
|
+
}
|
|
95
|
+
groups[group]["tools"].append(tool_entry)
|
|
96
|
+
groups[group]["timeline"].append({"type": "tool", "tool": tool_entry})
|
|
97
|
+
elif event.type == EventType.TOOL_END:
|
|
98
|
+
if group in groups:
|
|
99
|
+
tool_use_id = event.data.get("tool_use_id")
|
|
100
|
+
status = event.data.get("status", "done")
|
|
101
|
+
result = event.data.get("tool_result", "")
|
|
102
|
+
for tool in groups[group]["tools"]:
|
|
103
|
+
if tool["toolUseId"] == tool_use_id:
|
|
104
|
+
tool["status"] = status
|
|
105
|
+
tool["toolResult"] = result
|
|
106
|
+
break
|
|
107
|
+
elif event.type == EventType.TOKEN:
|
|
108
|
+
if group in groups:
|
|
109
|
+
text = event.data.get("text", "")
|
|
110
|
+
groups[group]["tokens"] += text
|
|
111
|
+
timeline = groups[group]["timeline"]
|
|
112
|
+
if timeline and timeline[-1]["type"] == "text":
|
|
113
|
+
timeline[-1]["text"] += text
|
|
114
|
+
else:
|
|
115
|
+
timeline.append({"type": "text", "text": text})
|
|
116
|
+
elif event.type == EventType.AGENT_COMPLETE:
|
|
117
|
+
if group in groups and "structured_output" in event.data:
|
|
118
|
+
groups[group]["structuredOutput"] = event.data["structured_output"]
|
|
119
|
+
groups[group]["outputSchemaName"] = event.data.get("output_schema_name")
|
|
120
|
+
elif event.type == EventType.INTERRUPT:
|
|
121
|
+
if group in groups:
|
|
122
|
+
groups[group]["status"] = "interrupted"
|
|
123
|
+
groups[group]["interrupt"] = {
|
|
124
|
+
"id": event.data.get("interrupt_id"),
|
|
125
|
+
"name": event.data.get("name"),
|
|
126
|
+
"reason": event.data.get("reason"),
|
|
127
|
+
}
|
|
128
|
+
elif event.type == EventType.ERROR:
|
|
129
|
+
if group in groups:
|
|
130
|
+
groups[group]["status"] = "error"
|
|
131
|
+
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ActivityRegistry:
|
|
136
|
+
"""Thread-scoped builder of hierarchical activity state.
|
|
137
|
+
|
|
138
|
+
One instance is created per :func:`create_agui_app` and captured in the
|
|
139
|
+
endpoint closures. All mutation is guarded by a lock so concurrent request
|
|
140
|
+
tasks stay consistent. Snapshots are read via :meth:`snapshot` and emitted
|
|
141
|
+
on the run stream as ``ACTIVITY_SNAPSHOT`` events.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
self._states: dict[str, dict[str, Any]] = {}
|
|
146
|
+
self._lock = threading.Lock()
|
|
147
|
+
|
|
148
|
+
def _state_for(self, thread_id: str) -> dict[str, Any]:
|
|
149
|
+
return self._states.setdefault(thread_id, {"groups": {}})
|
|
150
|
+
|
|
151
|
+
def apply(self, thread_id: str, event: StreamEvent) -> bool:
|
|
152
|
+
"""Fold *event* into *thread_id*'s state.
|
|
153
|
+
|
|
154
|
+
Returns ``True`` when the state changed (caller should emit a fresh
|
|
155
|
+
snapshot), ``False`` for events that carry no stream group.
|
|
156
|
+
"""
|
|
157
|
+
with self._lock:
|
|
158
|
+
state = self._state_for(thread_id)
|
|
159
|
+
return _update_activity_state(state, event)
|
|
160
|
+
|
|
161
|
+
def snapshot(self, thread_id: str) -> dict[str, Any]:
|
|
162
|
+
"""Return a deep copy of the activity state for *thread_id* (``{groups: {...}}``).
|
|
163
|
+
|
|
164
|
+
Deep-copied because the returned snapshot is encoded later on the SSE
|
|
165
|
+
loop, after the lock is released, while subsequent events may still be
|
|
166
|
+
mutating the live state.
|
|
167
|
+
"""
|
|
168
|
+
with self._lock:
|
|
169
|
+
state = self._states.get(thread_id)
|
|
170
|
+
if state is None:
|
|
171
|
+
return {"groups": {}}
|
|
172
|
+
return copy.deepcopy(state)
|