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.
- aigenos_flow/__init__.py +140 -0
- aigenos_flow/_slack_helper.py +96 -0
- aigenos_flow/agent.py +177 -0
- aigenos_flow/agui/__init__.py +0 -0
- aigenos_flow/agui/events.py +135 -0
- aigenos_flow/agui/protocols/__init__.py +30 -0
- aigenos_flow/agui/protocols/a2ui.py +89 -0
- aigenos_flow/agui/protocols/mcp_ui.py +111 -0
- aigenos_flow/agui/stream.py +17 -0
- aigenos_flow/ai/__init__.py +0 -0
- aigenos_flow/ai/generator.py +99 -0
- aigenos_flow/ai/prompts.py +133 -0
- aigenos_flow/ai/providers/__init__.py +0 -0
- aigenos_flow/ai/providers/anthropic_provider.py +50 -0
- aigenos_flow/ai/providers/ollama_provider.py +75 -0
- aigenos_flow/autowidget.py +232 -0
- aigenos_flow/connectors/__init__.py +30 -0
- aigenos_flow/connectors/api.py +322 -0
- aigenos_flow/connectors/manager.py +474 -0
- aigenos_flow/connectors/models.py +111 -0
- aigenos_flow/connectors/mongodb.py +296 -0
- aigenos_flow/connectors/postgresql.py +246 -0
- aigenos_flow/connectors/secrets.py +82 -0
- aigenos_flow/connectors/serialization.py +45 -0
- aigenos_flow/connectors/store.py +407 -0
- aigenos_flow/context.py +347 -0
- aigenos_flow/data/widgets/duration_picker.json +88 -0
- aigenos_flow/data/widgets/order_id_input.json +39 -0
- aigenos_flow/data/widgets/order_summary.json +41 -0
- aigenos_flow/data/widgets/refund_confirm.json +48 -0
- aigenos_flow/data/widgets/subscription_details.json +157 -0
- aigenos_flow/data/widgets/subscription_grid.json +68 -0
- aigenos_flow/data/workflows/refund_flow.yaml +63 -0
- aigenos_flow/data/workflows/workflow2.yaml +37 -0
- aigenos_flow/declarative.py +628 -0
- aigenos_flow/decorators.py +98 -0
- aigenos_flow/engine/__init__.py +0 -0
- aigenos_flow/engine/bindings.py +34 -0
- aigenos_flow/engine/loader.py +90 -0
- aigenos_flow/engine/runtime.py +190 -0
- aigenos_flow/engine/safe_eval.py +182 -0
- aigenos_flow/fields.py +155 -0
- aigenos_flow/guarantees.py +205 -0
- aigenos_flow/host.py +91 -0
- aigenos_flow/hosts/__init__.py +9 -0
- aigenos_flow/hosts/langgraph_host.py +459 -0
- aigenos_flow/hosts/test_host.py +233 -0
- aigenos_flow/integration.py +486 -0
- aigenos_flow/integrations/__init__.py +0 -0
- aigenos_flow/integrations/databases.py +332 -0
- aigenos_flow/integrations/flow.py +1379 -0
- aigenos_flow/integrations/langgraph.py +194 -0
- aigenos_flow/interpret.py +915 -0
- aigenos_flow/main.py +288 -0
- aigenos_flow/outline.py +187 -0
- aigenos_flow/runtime.py +791 -0
- aigenos_flow/scenarios.py +216 -0
- aigenos_flow/schemas/__init__.py +0 -0
- aigenos_flow/schemas/widget_spec.py +29 -0
- aigenos_flow/schemas/workflow.py +37 -0
- aigenos_flow/skill_generator.py +727 -0
- aigenos_flow/skills.py +1027 -0
- aigenos_flow/store.py +197 -0
- aigenos_flow/testing.py +143 -0
- aigenos_flow/trace.py +84 -0
- aigenos_flow-0.3.0.dist-info/METADATA +137 -0
- aigenos_flow-0.3.0.dist-info/RECORD +69 -0
- aigenos_flow-0.3.0.dist-info/WHEEL +4 -0
- aigenos_flow-0.3.0.dist-info/licenses/LICENSE +202 -0
aigenos_flow/__init__.py
ADDED
|
@@ -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)}
|