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.
Files changed (68) hide show
  1. kaboo_workflows/__init__.py +66 -0
  2. kaboo_workflows/_context.py +135 -0
  3. kaboo_workflows/adapters/__init__.py +7 -0
  4. kaboo_workflows/adapters/_activity.py +172 -0
  5. kaboo_workflows/adapters/_multiagent.py +387 -0
  6. kaboo_workflows/adapters/_strands_bridge.py +107 -0
  7. kaboo_workflows/adapters/agui.py +958 -0
  8. kaboo_workflows/cli.py +434 -0
  9. kaboo_workflows/config/__init__.py +53 -0
  10. kaboo_workflows/config/interpolation.py +196 -0
  11. kaboo_workflows/config/loaders/__init__.py +12 -0
  12. kaboo_workflows/config/loaders/helpers.py +409 -0
  13. kaboo_workflows/config/loaders/loaders.py +292 -0
  14. kaboo_workflows/config/loaders/validators.py +124 -0
  15. kaboo_workflows/config/resolvers/__init__.py +28 -0
  16. kaboo_workflows/config/resolvers/agents.py +255 -0
  17. kaboo_workflows/config/resolvers/config.py +466 -0
  18. kaboo_workflows/config/resolvers/conversation_manager.py +54 -0
  19. kaboo_workflows/config/resolvers/hooks.py +68 -0
  20. kaboo_workflows/config/resolvers/mcp.py +106 -0
  21. kaboo_workflows/config/resolvers/models.py +47 -0
  22. kaboo_workflows/config/resolvers/orchestrations/__init__.py +81 -0
  23. kaboo_workflows/config/resolvers/orchestrations/builders.py +409 -0
  24. kaboo_workflows/config/resolvers/orchestrations/planner.py +103 -0
  25. kaboo_workflows/config/resolvers/schema_prompt.py +67 -0
  26. kaboo_workflows/config/resolvers/session_manager.py +183 -0
  27. kaboo_workflows/config/schema.py +457 -0
  28. kaboo_workflows/converters/__init__.py +13 -0
  29. kaboo_workflows/converters/base.py +54 -0
  30. kaboo_workflows/converters/openai.py +381 -0
  31. kaboo_workflows/converters/raw.py +31 -0
  32. kaboo_workflows/exceptions.py +27 -0
  33. kaboo_workflows/hooks/__init__.py +21 -0
  34. kaboo_workflows/hooks/event_publisher.py +600 -0
  35. kaboo_workflows/hooks/history_hook.py +145 -0
  36. kaboo_workflows/hooks/interrupt_hook.py +52 -0
  37. kaboo_workflows/hooks/max_calls_guard.py +90 -0
  38. kaboo_workflows/hooks/stop_guard.py +113 -0
  39. kaboo_workflows/hooks/tool_name_sanitizer.py +184 -0
  40. kaboo_workflows/manifest.py +306 -0
  41. kaboo_workflows/mcp/README.md +105 -0
  42. kaboo_workflows/mcp/__init__.py +27 -0
  43. kaboo_workflows/mcp/client.py +154 -0
  44. kaboo_workflows/mcp/lifecycle.py +246 -0
  45. kaboo_workflows/mcp/server.py +327 -0
  46. kaboo_workflows/mcp/transports.py +188 -0
  47. kaboo_workflows/models.py +69 -0
  48. kaboo_workflows/py.typed +0 -0
  49. kaboo_workflows/renderers/__init__.py +9 -0
  50. kaboo_workflows/renderers/ansi.py +295 -0
  51. kaboo_workflows/renderers/base.py +36 -0
  52. kaboo_workflows/serve.py +64 -0
  53. kaboo_workflows/startup/__init__.py +24 -0
  54. kaboo_workflows/startup/report.py +174 -0
  55. kaboo_workflows/startup/validator.py +167 -0
  56. kaboo_workflows/tools/__init__.py +38 -0
  57. kaboo_workflows/tools/ask_user.py +76 -0
  58. kaboo_workflows/tools/extractors.py +154 -0
  59. kaboo_workflows/tools/loaders.py +259 -0
  60. kaboo_workflows/tools/wrappers.py +158 -0
  61. kaboo_workflows/types.py +321 -0
  62. kaboo_workflows/utils.py +209 -0
  63. kaboo_workflows/wire.py +317 -0
  64. kaboo_workflows-0.9.0.dist-info/METADATA +364 -0
  65. kaboo_workflows-0.9.0.dist-info/RECORD +68 -0
  66. kaboo_workflows-0.9.0.dist-info/WHEEL +4 -0
  67. kaboo_workflows-0.9.0.dist-info/entry_points.txt +3 -0
  68. 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,7 @@
1
+ """AG-UI adapter for kaboo-workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .agui import create_agui_app
6
+
7
+ __all__ = ["create_agui_app"]
@@ -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)