aigenos-flow 0.3.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.

Potentially problematic release.


This version of aigenos-flow might be problematic. Click here for more details.

Files changed (69) hide show
  1. aigenos_flow/__init__.py +140 -0
  2. aigenos_flow/_slack_helper.py +96 -0
  3. aigenos_flow/agent.py +177 -0
  4. aigenos_flow/agui/__init__.py +0 -0
  5. aigenos_flow/agui/events.py +135 -0
  6. aigenos_flow/agui/protocols/__init__.py +30 -0
  7. aigenos_flow/agui/protocols/a2ui.py +89 -0
  8. aigenos_flow/agui/protocols/mcp_ui.py +111 -0
  9. aigenos_flow/agui/stream.py +17 -0
  10. aigenos_flow/ai/__init__.py +0 -0
  11. aigenos_flow/ai/generator.py +99 -0
  12. aigenos_flow/ai/prompts.py +133 -0
  13. aigenos_flow/ai/providers/__init__.py +0 -0
  14. aigenos_flow/ai/providers/anthropic_provider.py +50 -0
  15. aigenos_flow/ai/providers/ollama_provider.py +75 -0
  16. aigenos_flow/autowidget.py +232 -0
  17. aigenos_flow/connectors/__init__.py +30 -0
  18. aigenos_flow/connectors/api.py +322 -0
  19. aigenos_flow/connectors/manager.py +474 -0
  20. aigenos_flow/connectors/models.py +111 -0
  21. aigenos_flow/connectors/mongodb.py +296 -0
  22. aigenos_flow/connectors/postgresql.py +246 -0
  23. aigenos_flow/connectors/secrets.py +82 -0
  24. aigenos_flow/connectors/serialization.py +45 -0
  25. aigenos_flow/connectors/store.py +407 -0
  26. aigenos_flow/context.py +347 -0
  27. aigenos_flow/data/widgets/duration_picker.json +88 -0
  28. aigenos_flow/data/widgets/order_id_input.json +39 -0
  29. aigenos_flow/data/widgets/order_summary.json +41 -0
  30. aigenos_flow/data/widgets/refund_confirm.json +48 -0
  31. aigenos_flow/data/widgets/subscription_details.json +157 -0
  32. aigenos_flow/data/widgets/subscription_grid.json +68 -0
  33. aigenos_flow/data/workflows/refund_flow.yaml +63 -0
  34. aigenos_flow/data/workflows/workflow2.yaml +37 -0
  35. aigenos_flow/declarative.py +628 -0
  36. aigenos_flow/decorators.py +98 -0
  37. aigenos_flow/engine/__init__.py +0 -0
  38. aigenos_flow/engine/bindings.py +34 -0
  39. aigenos_flow/engine/loader.py +90 -0
  40. aigenos_flow/engine/runtime.py +190 -0
  41. aigenos_flow/engine/safe_eval.py +182 -0
  42. aigenos_flow/fields.py +155 -0
  43. aigenos_flow/guarantees.py +205 -0
  44. aigenos_flow/host.py +91 -0
  45. aigenos_flow/hosts/__init__.py +9 -0
  46. aigenos_flow/hosts/langgraph_host.py +459 -0
  47. aigenos_flow/hosts/test_host.py +233 -0
  48. aigenos_flow/integration.py +486 -0
  49. aigenos_flow/integrations/__init__.py +0 -0
  50. aigenos_flow/integrations/databases.py +332 -0
  51. aigenos_flow/integrations/flow.py +1379 -0
  52. aigenos_flow/integrations/langgraph.py +194 -0
  53. aigenos_flow/interpret.py +915 -0
  54. aigenos_flow/main.py +288 -0
  55. aigenos_flow/outline.py +187 -0
  56. aigenos_flow/runtime.py +791 -0
  57. aigenos_flow/scenarios.py +216 -0
  58. aigenos_flow/schemas/__init__.py +0 -0
  59. aigenos_flow/schemas/widget_spec.py +29 -0
  60. aigenos_flow/schemas/workflow.py +37 -0
  61. aigenos_flow/skill_generator.py +727 -0
  62. aigenos_flow/skills.py +1027 -0
  63. aigenos_flow/store.py +197 -0
  64. aigenos_flow/testing.py +143 -0
  65. aigenos_flow/trace.py +84 -0
  66. aigenos_flow-0.3.0.dist-info/METADATA +137 -0
  67. aigenos_flow-0.3.0.dist-info/RECORD +69 -0
  68. aigenos_flow-0.3.0.dist-info/WHEEL +4 -0
  69. aigenos_flow-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,140 @@
1
+ """aigenos-flow — deterministic conversational flows + a drop-in widget layer.
2
+
3
+ One package. The deterministic flow engine lives right here at the package
4
+ root; the FastAPI widget server (``aigenos_flow.main`` / ``aigenos_flow.agui``
5
+ / ``aigenos_flow.integrations``) ships under the ``[server]`` extra.
6
+
7
+ Quickstart:
8
+
9
+ from aigenos_flow import FlowAgent, flow, action
10
+
11
+ # skills/file_expense/SKILL.md + handlers.py defines the flow
12
+ agent = FlowAgent.from_dir("skills/", host=my_host)
13
+ graph = agent.as_langgraph()
14
+
15
+ Public API (stable for v0.x):
16
+
17
+ FlowAgent — load skills, compile to LangGraph
18
+ @flow — decorator: marks the entrypoint of a Skill's flow
19
+ @action — decorator: marks a memoized side-effectful step
20
+ Context — the ``ctx`` passed to flow functions (ask/run/say)
21
+ FieldSpec — typed field declaration (also valid in YAML)
22
+ Reply, SetField, Correct, — what the interpreter LLM emits per turn
23
+ Cancel, StartFlow,
24
+ OutOfScope, Chitchat
25
+ FlowState — the TypedDict that threads through every node
26
+ Host — Protocol every framework adapter implements
27
+
28
+ Host adapters live at ``aigenos_flow.hosts`` (``LangGraphHost``, ``TestHost``).
29
+ """
30
+ from .agent import FlowAgent
31
+ from .context import Context, FlowDone, FlowLink, NeedField
32
+ from .declarative import ActionSpec, AuditSink, ToolRegistry, build_declarative_flow
33
+ from .context import Context, FlowDone, FlowLink, NeedField, NeedFields
34
+ from .decorators import action, flow
35
+ from .fields import FieldSpec, FieldValidationError
36
+ from .host import Host
37
+ from .integration import build_react_with_skills, message_text
38
+ from .interpret import (
39
+ Cancel,
40
+ Chitchat,
41
+ Correct,
42
+ OutOfScope,
43
+ Reply,
44
+ SetField,
45
+ StartFlow,
46
+ Turn,
47
+ TurnInterpretation,
48
+ )
49
+ from .runtime import (
50
+ FlowStackFrame,
51
+ FlowState,
52
+ Message,
53
+ initial_state,
54
+ )
55
+ from .skills import Skill, SkillFrontmatter, SkillRegistry
56
+ from ._slack_helper import post_to_slack
57
+ from .connectors import (
58
+ ConnectorAdapter,
59
+ ConnectorManager,
60
+ ConnectorResult,
61
+ ConnectorStore,
62
+ InvocationContext,
63
+ ManagedOperation,
64
+ ParameterSpec,
65
+ SecretResolver,
66
+ ToolDescriptor,
67
+ )
68
+
69
+ # Back-compat aliases — old code that imports SlotSpec / SetSlot / NeedSlot /
70
+ # CommandList still works. Marked DEPRECATED in docs; will be removed in 0.3.
71
+ SlotSpec = FieldSpec
72
+ SlotValidationError = FieldValidationError
73
+ SetSlot = SetField
74
+ NeedSlot = NeedField
75
+ CommandList = TurnInterpretation
76
+ Command = Turn
77
+
78
+ __all__ = [
79
+ # high level
80
+ "FlowAgent",
81
+ "flow",
82
+ "action",
83
+ "build_react_with_skills",
84
+ "message_text",
85
+ # core types
86
+ "Context",
87
+ "Skill",
88
+ "SkillFrontmatter",
89
+ "SkillRegistry",
90
+ "FieldSpec",
91
+ "FieldValidationError",
92
+ # declarative skills
93
+ "ActionSpec",
94
+ "ToolRegistry",
95
+ "AuditSink",
96
+ "build_declarative_flow",
97
+ # managed connectors
98
+ "ConnectorAdapter",
99
+ "ConnectorManager",
100
+ "ConnectorResult",
101
+ "ConnectorStore",
102
+ "InvocationContext",
103
+ "ManagedOperation",
104
+ "ParameterSpec",
105
+ "SecretResolver",
106
+ "ToolDescriptor",
107
+ # interpretation vocabulary
108
+ "Turn",
109
+ "TurnInterpretation",
110
+ "SetField",
111
+ "Correct",
112
+ "StartFlow",
113
+ "Cancel",
114
+ "OutOfScope",
115
+ "Chitchat",
116
+ "Reply",
117
+ # state
118
+ "FlowState",
119
+ "FlowStackFrame",
120
+ "Message",
121
+ "initial_state",
122
+ # exception sentinels
123
+ "FlowDone",
124
+ "FlowLink",
125
+ "NeedField",
126
+ "NeedFields",
127
+ # host protocol
128
+ "Host",
129
+ # integrations
130
+ "post_to_slack",
131
+ # back-compat
132
+ "SlotSpec",
133
+ "SlotValidationError",
134
+ "SetSlot",
135
+ "NeedSlot",
136
+ "CommandList",
137
+ "Command",
138
+ ]
139
+
140
+ __version__ = "0.3.0"
@@ -0,0 +1,96 @@
1
+ """Tiny Slack incoming-webhook helper used by generated skill actions.
2
+
3
+ Why this lives in the engine: the skill_generator emits handlers.py files
4
+ whose `@action` stubs call ``post_to_slack(...)`` when ``SLACK_WEBHOOK_URL``
5
+ is set in the environment. Generated actions degrade gracefully — without
6
+ the env var they just return their fake_result, so demos still work offline.
7
+
8
+ Usage in a generated action::
9
+
10
+ @action
11
+ def submit_refund(state):
12
+ info = post_to_slack(
13
+ "Refund request",
14
+ fields={"order_id": state["fields"]["order_id"], "amount": state["fields"]["amount"]},
15
+ )
16
+ # info["posted"] is True if a webhook was hit, False otherwise.
17
+ return {"refund_id": "RFD-4242", "status": "pending", "slack": info}
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ from typing import Any
25
+ from urllib import error, request
26
+
27
+ log = logging.getLogger("aigenos_flow.slack")
28
+
29
+
30
+ def post_to_slack(
31
+ title: str,
32
+ *,
33
+ fields: dict[str, Any] | None = None,
34
+ webhook_url: str | None = None,
35
+ timeout: float = 5.0,
36
+ ) -> dict[str, Any]:
37
+ """Post a formatted message to a Slack incoming webhook.
38
+
39
+ Args:
40
+ title: Bolded header line (e.g. "New refund request").
41
+ fields: Optional key/value pairs rendered as a bulleted block under the title.
42
+ webhook_url: Override the SLACK_WEBHOOK_URL env var.
43
+ timeout: HTTP timeout in seconds.
44
+
45
+ Returns:
46
+ ``{"posted": bool, "status": int | None, "error": str | None}``.
47
+ Never raises — silent skip when the webhook isn't configured so the
48
+ demo continues working without any setup.
49
+ """
50
+ url = webhook_url or os.getenv("SLACK_WEBHOOK_URL")
51
+ if not url:
52
+ log.info("[slack] SLACK_WEBHOOK_URL not set — skipping post")
53
+ return {"posted": False, "status": None, "error": "SLACK_WEBHOOK_URL not set"}
54
+
55
+ # Slack Block Kit payload — renders nicely in the channel.
56
+ blocks: list[dict[str, Any]] = [
57
+ {
58
+ "type": "header",
59
+ "text": {"type": "plain_text", "text": title, "emoji": True},
60
+ }
61
+ ]
62
+ if fields:
63
+ field_lines = "\n".join(f"• *{k}:* {v}" for k, v in fields.items())
64
+ blocks.append(
65
+ {
66
+ "type": "section",
67
+ "text": {"type": "mrkdwn", "text": field_lines},
68
+ }
69
+ )
70
+ blocks.append(
71
+ {
72
+ "type": "context",
73
+ "elements": [
74
+ {"type": "mrkdwn", "text": "_Posted by an aigenos-flows skill_"},
75
+ ],
76
+ }
77
+ )
78
+
79
+ payload = json.dumps({"text": title, "blocks": blocks}).encode("utf-8")
80
+ req = request.Request(
81
+ url, data=payload, headers={"content-type": "application/json"}
82
+ )
83
+ try:
84
+ with request.urlopen(req, timeout=timeout) as resp:
85
+ status = resp.getcode()
86
+ log.info("[slack] posted %r → status=%s", title, status)
87
+ return {"posted": True, "status": status, "error": None}
88
+ except error.HTTPError as exc:
89
+ log.warning("[slack] post failed: HTTP %s", exc.code)
90
+ return {"posted": False, "status": exc.code, "error": f"HTTP {exc.code}"}
91
+ except error.URLError as exc:
92
+ log.warning("[slack] post failed: %s", exc.reason)
93
+ return {"posted": False, "status": None, "error": str(exc.reason)}
94
+ except Exception as exc: # noqa: BLE001 — never raise to skill code
95
+ log.exception("[slack] unexpected error")
96
+ return {"posted": False, "status": None, "error": str(exc)}
aigenos_flow/agent.py ADDED
@@ -0,0 +1,177 @@
1
+ """FlowAgent: loads a skills directory and compiles it to either a
2
+ self-contained LangGraph (via ``as_langgraph()``) or a single node you
3
+ can plug into an existing graph (``turn()``).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .declarative import AuditSink, ToolRegistry
11
+ from .host import Host
12
+ from .runtime import ( # noqa: F401 — FlowState re-exported for type hints
13
+ FlowState,
14
+ resume_state_updates,
15
+ run_turn,
16
+ )
17
+ from .skills import SkillRegistry, load_skills_dir
18
+
19
+
20
+ class FlowAgent:
21
+ """A loaded set of skills plus a host. Knows how to compile itself into
22
+ different framework targets.
23
+
24
+ Two response modes:
25
+
26
+ - ``"flex"`` (default): the LLM is asked to always include a natural
27
+ conversational Reply alongside any structured items. The agent feels
28
+ like an LLM chatbot whose field-filling flow runs on top.
29
+ - ``"strict"``: the LLM is asked only to interpret items; Reply is
30
+ optional. The agent reads more like a form. Use this when latency,
31
+ cost, or determinism of the assistant's literal words matters.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ registry: SkillRegistry,
37
+ host: Host,
38
+ *,
39
+ mode: str = "flex",
40
+ ):
41
+ if mode not in {"flex", "strict"}:
42
+ raise ValueError(f"mode must be 'flex' or 'strict', got {mode!r}")
43
+ self.registry = registry
44
+ self.host = host
45
+ self.mode = mode
46
+
47
+ @classmethod
48
+ def from_dir(
49
+ cls,
50
+ skills_dir: Path | str,
51
+ host: Host,
52
+ *,
53
+ mode: str = "flex",
54
+ tools: Any = None,
55
+ audit: AuditSink | None = None,
56
+ ) -> "FlowAgent":
57
+ """Load a skills directory.
58
+
59
+ ``tools`` grants the host tools that declarative ``actions:`` may
60
+ reference — a list of callables / LangChain tools, a name->fn dict, or a
61
+ ToolRegistry. Imperative skills (and pure-compute declarative skills)
62
+ need no tools. ``audit`` overrides the default in-process audit sink.
63
+ """
64
+ registry = load_skills_dir(
65
+ skills_dir, tool_registry=ToolRegistry.from_tools(tools), audit=audit
66
+ )
67
+ if len(registry) == 0:
68
+ raise ValueError(f"no skills found in {skills_dir!r}")
69
+ return cls(registry=registry, host=host, mode=mode)
70
+
71
+ def list_skills(self) -> list[str]:
72
+ return [s.name for s in self.registry.all()]
73
+
74
+ # ----------------------------------------------------------------- LangGraph
75
+ def as_langgraph(self) -> Any:
76
+ """Compile to a LangGraph CompiledStateGraph.
77
+
78
+ Topology: START -> turn -> (done -> END) or (awaiting_user -> wait -> turn).
79
+
80
+ The turn/wait split is required so messages emitted before
81
+ interrupt() are checkpointed (interrupt raises before the node
82
+ returns, so a single node would lose them).
83
+ """
84
+ try:
85
+ from langgraph.graph import END, START, StateGraph
86
+ from langgraph.types import interrupt
87
+ except ImportError as e: # pragma: no cover
88
+ raise ImportError(
89
+ "as_langgraph() requires `langgraph` to be installed. "
90
+ "pip install langgraph"
91
+ ) from e
92
+
93
+ registry = self.registry
94
+ host = self.host
95
+ mode = self.mode
96
+
97
+ def turn_node(state: FlowState) -> dict[str, Any]:
98
+ state = run_turn(state, registry, host, mode=mode)
99
+ pending = host.drain_pending_messages()
100
+ updates: dict[str, Any] = {
101
+ "flow_id": state.get("flow_id"),
102
+ "fields": state.get("fields", {}),
103
+ "results": state.get("results", {}),
104
+ "say_cursor": state.get("say_cursor", 0),
105
+ "field_history": state.get("field_history", {}),
106
+ "history": state.get("history", []),
107
+ "pending_user_message": None,
108
+ # Always clear the widget-bypass key: run_turn consumes it and
109
+ # sets it to None on the state object, but the node's return
110
+ # dict is what LangGraph checkpoints — omitting it would leave a
111
+ # stale submission to re-fire next turn (the old confirm-loop).
112
+ "pending_field_updates": None,
113
+ "pending_assistant_messages": pending,
114
+ "pending_widget": state.get("pending_widget"),
115
+ "awaiting_user": bool(state.get("awaiting_user")),
116
+ "awaiting_field": state.get("awaiting_field"),
117
+ "flow_stack": state.get("flow_stack", []),
118
+ "done": bool(state.get("done")),
119
+ # Chat-leaf + effect-dedup state must survive checkpointing:
120
+ # chat routing reads chat_config on the NEXT turn, and the
121
+ # no-double-spend guard is keyed on flow_run_id in effects_log.
122
+ "chat_config": state.get("chat_config"),
123
+ "flow_run_id": state.get("flow_run_id"),
124
+ "effects_log": state.get("effects_log", {}),
125
+ }
126
+ if pending:
127
+ updates["history"] = list(updates["history"]) + [
128
+ {"role": "assistant", "content": p} for p in pending
129
+ ]
130
+ return updates
131
+
132
+ def wait_node(state: FlowState) -> dict[str, Any]:
133
+ pending = state.get("pending_assistant_messages") or []
134
+ prompt = (
135
+ pending[-1]
136
+ if pending
137
+ else f"Please provide {state.get('awaiting_field', 'input')}."
138
+ )
139
+ user_input = interrupt(
140
+ {
141
+ "prompt": prompt,
142
+ "messages": list(pending),
143
+ "field": state.get("awaiting_field"),
144
+ "flow_id": state.get("flow_id"),
145
+ }
146
+ )
147
+ # Shared routing: dict → structured widget submission (LLM bypass),
148
+ # str → free-form message. See runtime.resume_state_updates.
149
+ return {**resume_state_updates(user_input), "pending_assistant_messages": []}
150
+
151
+ def route(state: FlowState) -> str:
152
+ if state.get("done"):
153
+ return "end"
154
+ if state.get("awaiting_user"):
155
+ return "wait"
156
+ return "end"
157
+
158
+ graph = StateGraph(FlowState)
159
+ graph.add_node("turn", turn_node)
160
+ graph.add_node("wait", wait_node)
161
+ graph.add_edge(START, "turn")
162
+ graph.add_conditional_edges("turn", route, {"wait": "wait", "end": END})
163
+ graph.add_edge("wait", "turn")
164
+
165
+ return graph.compile(
166
+ checkpointer=getattr(host, "checkpointer", None),
167
+ )
168
+
169
+ # ----------------------------------------------------------------- bare runtime
170
+ def turn(self, state: dict[str, Any]) -> dict[str, Any]:
171
+ """Single-shot interface that doesn't require LangGraph at all.
172
+
173
+ Useful for tests and for embedding into any framework that doesn't
174
+ provide a checkpointed graph runtime. The caller is responsible for
175
+ persisting ``state`` between turns.
176
+ """
177
+ return run_turn(state, self.registry, self.host, mode=self.mode) # type: ignore[arg-type]
File without changes
@@ -0,0 +1,135 @@
1
+ """AG-UI protocol event types.
2
+
3
+ Subset of the AG-UI spec sufficient for text streaming + generative-UI tool
4
+ calls. Frontend renders widgets by treating TOOL_CALL_* events whose tool name
5
+ is `render_widget` as widget render requests.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import uuid
11
+ from typing import Any, Literal
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ def _ts() -> int:
17
+ return int(time.time() * 1000)
18
+
19
+
20
+ def _id() -> str:
21
+ return uuid.uuid4().hex
22
+
23
+
24
+ class BaseEvent(BaseModel):
25
+ type: str
26
+ timestamp: int = Field(default_factory=_ts)
27
+ raw_event: dict[str, Any] | None = None
28
+
29
+
30
+ class RunStarted(BaseEvent):
31
+ type: Literal["RUN_STARTED"] = "RUN_STARTED"
32
+ thread_id: str
33
+ run_id: str
34
+
35
+
36
+ class RunFinished(BaseEvent):
37
+ type: Literal["RUN_FINISHED"] = "RUN_FINISHED"
38
+ thread_id: str
39
+ run_id: str
40
+
41
+
42
+ class RunError(BaseEvent):
43
+ type: Literal["RUN_ERROR"] = "RUN_ERROR"
44
+ message: str
45
+ code: str | None = None
46
+
47
+
48
+ class StepStarted(BaseEvent):
49
+ type: Literal["STEP_STARTED"] = "STEP_STARTED"
50
+ step_name: str
51
+
52
+
53
+ class StepFinished(BaseEvent):
54
+ type: Literal["STEP_FINISHED"] = "STEP_FINISHED"
55
+ step_name: str
56
+
57
+
58
+ class TextMessageStart(BaseEvent):
59
+ type: Literal["TEXT_MESSAGE_START"] = "TEXT_MESSAGE_START"
60
+ message_id: str
61
+ role: Literal["assistant"] = "assistant"
62
+
63
+
64
+ class TextMessageContent(BaseEvent):
65
+ type: Literal["TEXT_MESSAGE_CONTENT"] = "TEXT_MESSAGE_CONTENT"
66
+ message_id: str
67
+ delta: str
68
+
69
+
70
+ class TextMessageEnd(BaseEvent):
71
+ type: Literal["TEXT_MESSAGE_END"] = "TEXT_MESSAGE_END"
72
+ message_id: str
73
+
74
+
75
+ class ToolCallStart(BaseEvent):
76
+ type: Literal["TOOL_CALL_START"] = "TOOL_CALL_START"
77
+ tool_call_id: str
78
+ tool_call_name: str
79
+ parent_message_id: str | None = None
80
+
81
+
82
+ class ToolCallArgs(BaseEvent):
83
+ type: Literal["TOOL_CALL_ARGS"] = "TOOL_CALL_ARGS"
84
+ tool_call_id: str
85
+ delta: str
86
+
87
+
88
+ class ToolCallEnd(BaseEvent):
89
+ type: Literal["TOOL_CALL_END"] = "TOOL_CALL_END"
90
+ tool_call_id: str
91
+
92
+
93
+ class ToolCallResult(BaseEvent):
94
+ type: Literal["TOOL_CALL_RESULT"] = "TOOL_CALL_RESULT"
95
+ tool_call_id: str
96
+ message_id: str
97
+ content: str
98
+ role: Literal["tool"] = "tool"
99
+
100
+
101
+ class StateSnapshot(BaseEvent):
102
+ type: Literal["STATE_SNAPSHOT"] = "STATE_SNAPSHOT"
103
+ snapshot: dict[str, Any]
104
+
105
+
106
+ class StateDelta(BaseEvent):
107
+ type: Literal["STATE_DELTA"] = "STATE_DELTA"
108
+ delta: list[dict[str, Any]]
109
+
110
+
111
+ class CustomEvent(BaseEvent):
112
+ type: Literal["CUSTOM"] = "CUSTOM"
113
+ name: str
114
+ value: dict[str, Any]
115
+
116
+
117
+ AnyEvent = (
118
+ RunStarted | RunFinished | RunError
119
+ | StepStarted | StepFinished
120
+ | TextMessageStart | TextMessageContent | TextMessageEnd
121
+ | ToolCallStart | ToolCallArgs | ToolCallEnd | ToolCallResult
122
+ | StateSnapshot | StateDelta | CustomEvent
123
+ )
124
+
125
+
126
+ def new_run_id() -> str:
127
+ return _id()
128
+
129
+
130
+ def new_message_id() -> str:
131
+ return _id()
132
+
133
+
134
+ def new_tool_call_id() -> str:
135
+ return _id()
@@ -0,0 +1,30 @@
1
+ """Protocol adapters.
2
+
3
+ Backend speaks AG-UI internally; adapters translate AG-UI events into other
4
+ emerging UI protocols (A2UI, MCP-UI). They produce SSE payloads in the target
5
+ protocol's shape.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import AsyncIterator
10
+
11
+ from ..events import AnyEvent
12
+ from ..stream import event_to_sse
13
+ from .a2ui import to_a2ui_sse
14
+ from .mcp_ui import to_mcp_ui_sse
15
+
16
+
17
+ SUPPORTED = ("ag-ui", "a2ui", "mcp-ui")
18
+
19
+
20
+ async def emit(protocol: str, events: AsyncIterator[AnyEvent]) -> AsyncIterator[dict[str, str]]:
21
+ proto = (protocol or "ag-ui").lower()
22
+ if proto == "a2ui":
23
+ async for sse in to_a2ui_sse(events):
24
+ yield sse
25
+ elif proto == "mcp-ui":
26
+ async for sse in to_mcp_ui_sse(events):
27
+ yield sse
28
+ else:
29
+ async for ev in events:
30
+ yield event_to_sse(ev)
@@ -0,0 +1,89 @@
1
+ """A2UI protocol adapter.
2
+
3
+ A2UI groups events into thinner semantic categories than AG-UI: messages,
4
+ ui_resources (widgets), state. We translate AG-UI tool calls named
5
+ `render_widget` into A2UI `ui_resource` events and stream text deltas as
6
+ `message_delta`. The wire spec is still evolving; this is a best-effort,
7
+ forward-compatible mapping that downstream A2UI clients can consume.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import AsyncIterator
13
+
14
+ from ..events import AnyEvent
15
+
16
+
17
+ async def to_a2ui_sse(events: AsyncIterator[AnyEvent]) -> AsyncIterator[dict[str, str]]:
18
+ pending_args: dict[str, str] = {}
19
+ pending_names: dict[str, str] = {}
20
+
21
+ async for ev in events:
22
+ t = ev.type
23
+
24
+ if t == "RUN_STARTED":
25
+ yield _sse("a2ui.run.start", {"thread_id": ev.thread_id, "run_id": ev.run_id})
26
+
27
+ elif t == "RUN_FINISHED":
28
+ yield _sse("a2ui.run.end", {"thread_id": ev.thread_id, "run_id": ev.run_id})
29
+
30
+ elif t == "RUN_ERROR":
31
+ yield _sse("a2ui.error", {"message": ev.message, "code": ev.code})
32
+
33
+ elif t == "TEXT_MESSAGE_START":
34
+ yield _sse("a2ui.message.start", {"id": ev.message_id, "role": ev.role})
35
+
36
+ elif t == "TEXT_MESSAGE_CONTENT":
37
+ yield _sse("a2ui.message.delta", {"id": ev.message_id, "delta": ev.delta})
38
+
39
+ elif t == "TEXT_MESSAGE_END":
40
+ yield _sse("a2ui.message.end", {"id": ev.message_id})
41
+
42
+ elif t == "TOOL_CALL_START":
43
+ pending_names[ev.tool_call_id] = ev.tool_call_name
44
+ pending_args[ev.tool_call_id] = ""
45
+
46
+ elif t == "TOOL_CALL_ARGS":
47
+ pending_args[ev.tool_call_id] = pending_args.get(ev.tool_call_id, "") + ev.delta
48
+
49
+ elif t == "TOOL_CALL_END":
50
+ args = pending_args.pop(ev.tool_call_id, "")
51
+ name = pending_names.pop(ev.tool_call_id, "")
52
+ try:
53
+ parsed = json.loads(args) if args else {}
54
+ except json.JSONDecodeError:
55
+ parsed = {"_raw": args}
56
+ if name == "render_widget":
57
+ yield _sse("a2ui.ui_resource", {
58
+ "id": ev.tool_call_id,
59
+ "kind": "widget",
60
+ "widget_id": parsed.get("widget_id"),
61
+ "version": parsed.get("version"),
62
+ "spec": parsed.get("spec"),
63
+ "submit_schema": parsed.get("submit_schema"),
64
+ })
65
+ else:
66
+ yield _sse("a2ui.tool_call", {"id": ev.tool_call_id, "name": name, "args": parsed})
67
+
68
+ elif t == "TOOL_CALL_RESULT":
69
+ try:
70
+ content = json.loads(ev.content)
71
+ except json.JSONDecodeError:
72
+ content = ev.content
73
+ yield _sse("a2ui.ui_resource_result", {"id": ev.tool_call_id, "result": content})
74
+
75
+ elif t == "STATE_SNAPSHOT":
76
+ yield _sse("a2ui.state", {"snapshot": ev.snapshot})
77
+
78
+ elif t == "STATE_DELTA":
79
+ yield _sse("a2ui.state.delta", {"delta": ev.delta})
80
+
81
+ elif t == "STEP_STARTED":
82
+ yield _sse("a2ui.step.start", {"name": ev.step_name})
83
+
84
+ elif t == "STEP_FINISHED":
85
+ yield _sse("a2ui.step.end", {"name": ev.step_name})
86
+
87
+
88
+ def _sse(event_name: str, data: dict) -> dict[str, str]:
89
+ return {"event": event_name, "data": json.dumps(data)}