aigenos-flow 0.3.1__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 (82) hide show
  1. aigenos_flow/__init__.py +152 -0
  2. aigenos_flow/_server_entry.py +18 -0
  3. aigenos_flow/_slack_helper.py +96 -0
  4. aigenos_flow/agent.py +361 -0
  5. aigenos_flow/agui/__init__.py +0 -0
  6. aigenos_flow/agui/events.py +135 -0
  7. aigenos_flow/agui/protocols/__init__.py +42 -0
  8. aigenos_flow/agui/protocols/a2ui.py +89 -0
  9. aigenos_flow/agui/protocols/mcp_ui.py +149 -0
  10. aigenos_flow/agui/stream.py +17 -0
  11. aigenos_flow/ai/__init__.py +0 -0
  12. aigenos_flow/ai/generator.py +99 -0
  13. aigenos_flow/ai/llm.py +143 -0
  14. aigenos_flow/ai/prompts.py +133 -0
  15. aigenos_flow/ai/providers/__init__.py +0 -0
  16. aigenos_flow/ai/providers/anthropic_provider.py +50 -0
  17. aigenos_flow/ai/providers/ollama_provider.py +75 -0
  18. aigenos_flow/approvals.py +135 -0
  19. aigenos_flow/autowidget.py +346 -0
  20. aigenos_flow/connectors/__init__.py +30 -0
  21. aigenos_flow/connectors/api.py +322 -0
  22. aigenos_flow/connectors/manager.py +532 -0
  23. aigenos_flow/connectors/models.py +122 -0
  24. aigenos_flow/connectors/mongodb.py +296 -0
  25. aigenos_flow/connectors/postgresql.py +246 -0
  26. aigenos_flow/connectors/secrets.py +82 -0
  27. aigenos_flow/connectors/serialization.py +45 -0
  28. aigenos_flow/connectors/store.py +407 -0
  29. aigenos_flow/context.py +475 -0
  30. aigenos_flow/data/widgets/duration_picker.json +88 -0
  31. aigenos_flow/data/widgets/order_id_input.json +39 -0
  32. aigenos_flow/data/widgets/order_summary.json +41 -0
  33. aigenos_flow/data/widgets/refund_confirm.json +48 -0
  34. aigenos_flow/data/widgets/subscription_details.json +157 -0
  35. aigenos_flow/data/widgets/subscription_grid.json +68 -0
  36. aigenos_flow/data/widgets/trip_checkout.json +75 -0
  37. aigenos_flow/data/workflows/refund_flow.yaml +63 -0
  38. aigenos_flow/declarative.py +1298 -0
  39. aigenos_flow/decorators.py +132 -0
  40. aigenos_flow/engine/__init__.py +0 -0
  41. aigenos_flow/engine/bindings.py +34 -0
  42. aigenos_flow/engine/loader.py +90 -0
  43. aigenos_flow/engine/runtime.py +190 -0
  44. aigenos_flow/engine/safe_eval.py +206 -0
  45. aigenos_flow/fields.py +201 -0
  46. aigenos_flow/guarantees.py +340 -0
  47. aigenos_flow/host.py +91 -0
  48. aigenos_flow/hosts/__init__.py +9 -0
  49. aigenos_flow/hosts/langgraph_host.py +459 -0
  50. aigenos_flow/hosts/test_host.py +233 -0
  51. aigenos_flow/integration.py +1432 -0
  52. aigenos_flow/integrations/__init__.py +0 -0
  53. aigenos_flow/integrations/databases.py +332 -0
  54. aigenos_flow/integrations/flow.py +2243 -0
  55. aigenos_flow/integrations/langgraph.py +530 -0
  56. aigenos_flow/integrations/write_access.py +28 -0
  57. aigenos_flow/interpret.py +1815 -0
  58. aigenos_flow/main.py +628 -0
  59. aigenos_flow/mcp_tools.py +226 -0
  60. aigenos_flow/outline.py +296 -0
  61. aigenos_flow/procedure.py +676 -0
  62. aigenos_flow/py.typed +0 -0
  63. aigenos_flow/runtime.py +1936 -0
  64. aigenos_flow/scenarios.py +216 -0
  65. aigenos_flow/schemas/__init__.py +0 -0
  66. aigenos_flow/schemas/widget_spec.py +29 -0
  67. aigenos_flow/schemas/workflow.py +37 -0
  68. aigenos_flow/shortform.py +1473 -0
  69. aigenos_flow/skill_editor.py +222 -0
  70. aigenos_flow/skill_generator.py +959 -0
  71. aigenos_flow/skills.py +2190 -0
  72. aigenos_flow/store.py +197 -0
  73. aigenos_flow/testing.py +143 -0
  74. aigenos_flow/toolbox.py +643 -0
  75. aigenos_flow/trace.py +84 -0
  76. aigenos_flow/write_guard.py +144 -0
  77. aigenos_flow-0.3.1.dist-info/METADATA +414 -0
  78. aigenos_flow-0.3.1.dist-info/RECORD +82 -0
  79. aigenos_flow-0.3.1.dist-info/WHEEL +4 -0
  80. aigenos_flow-0.3.1.dist-info/entry_points.txt +2 -0
  81. aigenos_flow-0.3.1.dist-info/licenses/LICENSE +202 -0
  82. aigenos_flow-0.3.1.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,152 @@
1
+ """aigenos-flow: the LLM fills fields; your program does everything else.
2
+
3
+ A skill is a SKILL.md whose ``steps:`` the engine runs. The model turns what
4
+ the user types into typed answers. Which step comes next, which tool runs,
5
+ with which inputs, and whether it may run at all is decided in code. The
6
+ FastAPI widget server (``aigenos_flow.main`` / ``aigenos_flow.agui`` /
7
+ ``aigenos_flow.integrations``) ships under the ``[server]`` extra.
8
+
9
+ Quickstart::
10
+
11
+ from aigenos_flow import create_agent
12
+
13
+ agent = create_agent(llm, "skills/") # a LangGraph agent that runs your flows
14
+
15
+ # Or keep the LangGraph agent you already have and put the flows in front:
16
+ from aigenos_flow import wrap_agent
17
+
18
+ safe = wrap_agent(my_agent, skills_dir="skills/", llm=llm)
19
+
20
+ Public API (stable for v0.x):
21
+
22
+ create_agent, wrap_agent — a LangGraph agent whose workflows are flows
23
+ flow_only — keep a governed tool out of the model's reach
24
+ FlowAgent, FlowSession — the flow runtime on its own; a scripted
25
+ conversation for tests
26
+ readonly, Secret — mark a tool as a lookup; a masked input
27
+ @flow, @action, Context — a flow written in Python, for what steps cannot say
28
+ FieldSpec — a typed answer
29
+ Reply, SetField, Correct, — what the model may return for one turn
30
+ Cancel, StartFlow,
31
+ Restart, Reopen, Resume,
32
+ OutOfScope, Chitchat
33
+ FlowState — the state that threads through every node
34
+ Host — the protocol a framework adapter implements
35
+
36
+ Host adapters live at ``aigenos_flow.hosts`` (``LangGraphHost``, ``TestHost``).
37
+ """
38
+ from .agent import FlowAgent, FlowSession
39
+ from .context import Context, FlowDone, FlowLink, NeedField, NeedFields
40
+ from .declarative import ActionSpec, AuditSink, ToolRegistry, build_declarative_flow
41
+ from .decorators import action, flow
42
+ from .fields import FieldSpec, FieldValidationError
43
+ from .host import Host
44
+ from .toolbox import Secret, readonly
45
+ from .integration import (
46
+ build_react_with_skills,
47
+ create_agent,
48
+ flow_only,
49
+ governed_write_tools,
50
+ message_text,
51
+ wrap_agent,
52
+ )
53
+ from .interpret import (
54
+ Cancel,
55
+ Chitchat,
56
+ Correct,
57
+ OutOfScope,
58
+ Reopen,
59
+ Reply,
60
+ Restart,
61
+ Resume,
62
+ SetField,
63
+ StartFlow,
64
+ Turn,
65
+ TurnInterpretation,
66
+ )
67
+ from .runtime import (
68
+ FlowStackFrame,
69
+ FlowState,
70
+ Message,
71
+ initial_state,
72
+ )
73
+ from .skills import Skill, SkillFrontmatter, SkillRegistry
74
+ # Importable for handlers.py files that notify a Slack channel; not part of the API.
75
+ from ._slack_helper import post_to_slack # noqa: F401
76
+ from .connectors import (
77
+ ConnectorAdapter,
78
+ ConnectorManager,
79
+ ConnectorResult,
80
+ ConnectorStore,
81
+ InvocationContext,
82
+ ManagedOperation,
83
+ ParameterSpec,
84
+ SecretResolver,
85
+ ToolDescriptor,
86
+ )
87
+
88
+ __all__ = [
89
+ # building an agent
90
+ "create_agent",
91
+ "wrap_agent",
92
+ "build_react_with_skills",
93
+ "flow_only",
94
+ "governed_write_tools",
95
+ "message_text",
96
+ # the flow runtime
97
+ "FlowAgent",
98
+ "FlowSession",
99
+ "flow",
100
+ "action",
101
+ "Context",
102
+ "Skill",
103
+ "SkillFrontmatter",
104
+ "SkillRegistry",
105
+ "FieldSpec",
106
+ "FieldValidationError",
107
+ # declarative skills
108
+ "ActionSpec",
109
+ "ToolRegistry",
110
+ "AuditSink",
111
+ "build_declarative_flow",
112
+ # managed connectors
113
+ "ConnectorAdapter",
114
+ "ConnectorManager",
115
+ "ConnectorResult",
116
+ "ConnectorStore",
117
+ "InvocationContext",
118
+ "ManagedOperation",
119
+ "ParameterSpec",
120
+ "SecretResolver",
121
+ "ToolDescriptor",
122
+ # the tool library
123
+ "readonly",
124
+ "Secret",
125
+ # what the model may return for one turn
126
+ "Turn",
127
+ "TurnInterpretation",
128
+ "SetField",
129
+ "Correct",
130
+ "StartFlow",
131
+ "Cancel",
132
+ "Restart",
133
+ "Reopen",
134
+ "Resume",
135
+ "OutOfScope",
136
+ "Chitchat",
137
+ "Reply",
138
+ # state
139
+ "FlowState",
140
+ "FlowStackFrame",
141
+ "Message",
142
+ "initial_state",
143
+ # how a flow pauses and ends
144
+ "FlowDone",
145
+ "FlowLink",
146
+ "NeedField",
147
+ "NeedFields",
148
+ # host protocol
149
+ "Host",
150
+ ]
151
+
152
+ __version__ = "0.3.1"
@@ -0,0 +1,18 @@
1
+ """Console entry point for ``aigenos-flow-server``.
2
+
3
+ Kept free of server imports: ``pip install aigenos-flow`` installs this command
4
+ too, and without the ``[server]`` extra the real entry point cannot even be
5
+ imported. This says what is missing instead of printing a traceback.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> None:
11
+ try:
12
+ from .main import main as serve
13
+ except ModuleNotFoundError as exc:
14
+ raise SystemExit(
15
+ f"aigenos-flow-server needs the server extra ({exc.name!r} is not installed).\n"
16
+ ' pip install "aigenos-flow[server]"'
17
+ ) from None
18
+ serve(argv)
@@ -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/flow 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,361 @@
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
+ initial_state,
15
+ node_updates,
16
+ resume_state_updates,
17
+ run_turn,
18
+ absorb_step_failure,
19
+ )
20
+ from .skills import SkillRegistry, load_skills_dir
21
+
22
+
23
+ #: How many extra ``run_turn`` calls one user turn may make while the flow is
24
+ #: still live but waiting for nothing (replaying a suspended flow to its next
25
+ #: ask). Bounded so a runtime that makes no progress can never spin. A
26
+ #: ``ctx.link()`` to a flow that never pauses costs two (start it, come back),
27
+ #: so 3 ended a chain of two such links mid-way with nothing on screen.
28
+ _MAX_CONTINUATION_HOPS = 16
29
+
30
+
31
+ def _ungovern(tools: Any) -> Any:
32
+ """Replace ``flow_only`` shims with the real tools they guard.
33
+
34
+ ``flow_only`` hands the model a copy that refuses; the flow runtime is the
35
+ one caller entitled to the original. Keeping the unwrap here means the
36
+ same object can be passed to an agent and to ``tools=``.
37
+ """
38
+
39
+ def one(t: Any) -> Any:
40
+ meta = getattr(t, "metadata", None) or {}
41
+ if meta.get("aigenos_flow_only"):
42
+ return meta.get("aigenos_flow_only_target", t)
43
+ return t
44
+
45
+ if isinstance(tools, dict):
46
+ return {k: one(v) for k, v in tools.items()}
47
+ if isinstance(tools, (list, tuple, set)):
48
+ return [one(t) for t in tools]
49
+ return tools
50
+
51
+
52
+ class FlowAgent:
53
+ """A loaded set of skills plus a host. Knows how to compile itself into
54
+ different framework targets.
55
+
56
+ Two response modes:
57
+
58
+ - ``"flex"`` (default): the LLM is asked to always include a natural
59
+ conversational Reply alongside any structured items. The agent feels
60
+ like an LLM chatbot whose field-filling flow runs on top.
61
+ - ``"strict"``: the LLM is asked only to interpret items; Reply is
62
+ optional. The agent reads more like a form. Use this when latency,
63
+ cost, or determinism of the assistant's literal words matters.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ registry: SkillRegistry,
69
+ host: Host,
70
+ *,
71
+ mode: str = "flex",
72
+ ):
73
+ if mode not in {"flex", "strict"}:
74
+ raise ValueError(f"mode must be 'flex' or 'strict', got {mode!r}")
75
+ self.registry = registry
76
+ self.host = host
77
+ self.mode = mode
78
+
79
+ @classmethod
80
+ def from_dir(
81
+ cls,
82
+ skills_dir: Path | str,
83
+ host: Host,
84
+ *,
85
+ mode: str = "flex",
86
+ tools: Any = None,
87
+ audit: AuditSink | None = None,
88
+ allow_empty: bool = False,
89
+ tools_dir: Path | str | None = None,
90
+ ) -> "FlowAgent":
91
+ """Load a skills directory.
92
+
93
+ Tools in ``<skills_dir>/tools/`` (and ``tools.py``) are picked up by
94
+ themselves: that folder is the library every skill here can name.
95
+ ``tools_dir`` adds a library kept somewhere else.
96
+
97
+ ``tools`` grants the host tools that declarative ``actions:`` may
98
+ reference — a list of callables / LangChain tools, a name->fn dict, or a
99
+ ToolRegistry. Imperative skills (and pure-compute declarative skills)
100
+ need no tools. ``audit`` overrides the default in-process audit sink.
101
+
102
+ A tool wrapped with ``aigenos_flow.flow_only`` is unwrapped here: the
103
+ shim exists to keep a model from calling the tool directly, and the
104
+ flow is exactly the caller that is allowed to.
105
+
106
+ ``allow_empty`` permits a directory that contains no skills at all —
107
+ for a server whose first skill has not been authored yet. A directory
108
+ holding skills that all FAILED to load still raises either way: that is
109
+ a broken deployment, not an empty one.
110
+ """
111
+ registry = load_skills_dir(
112
+ skills_dir,
113
+ tool_registry=ToolRegistry.from_tools(_ungovern(tools)),
114
+ audit=audit,
115
+ tools_dir=tools_dir,
116
+ )
117
+ if len(registry) == 0:
118
+ failed = getattr(registry, "errors", {}) or {}
119
+ if failed:
120
+ detail = "; ".join(f"{name}: {err}" for name, err in failed.items())
121
+ raise ValueError(
122
+ f"no skill in {skills_dir!r} could be loaded — "
123
+ f"{len(failed)} failed: {detail}"
124
+ )
125
+ if not allow_empty:
126
+ raise ValueError(
127
+ f"no skills found in {skills_dir!r}. Point it at a folder of "
128
+ "SKILL.md directories, or pass allow_empty=True if starting "
129
+ "with none is intended."
130
+ )
131
+ return cls(registry=registry, host=host, mode=mode)
132
+
133
+ def list_skills(self) -> list[str]:
134
+ return [s.name for s in self.registry.all()]
135
+
136
+ # ----------------------------------------------------------------- LangGraph
137
+ def as_langgraph(self) -> Any:
138
+ """Compile to a LangGraph CompiledStateGraph.
139
+
140
+ Topology: START -> turn -> (done -> END) or (awaiting_user -> wait -> turn).
141
+
142
+ The turn/wait split is required so messages emitted before
143
+ interrupt() are checkpointed (interrupt raises before the node
144
+ returns, so a single node would lose them).
145
+ """
146
+ try:
147
+ from langgraph.graph import END, START, StateGraph
148
+ from langgraph.types import interrupt
149
+ except ImportError as e: # pragma: no cover
150
+ raise ImportError(
151
+ "as_langgraph() requires `langgraph` to be installed. "
152
+ "pip install langgraph"
153
+ ) from e
154
+
155
+ registry = self.registry
156
+ host = self.host
157
+ mode = self.mode
158
+
159
+ turn = self.turn
160
+
161
+ def turn_node(state: FlowState) -> dict[str, Any]:
162
+ # FlowAgent.turn replays to the flow's next pause, so a pop back
163
+ # from a digression ends the turn on a real question instead of
164
+ # "back to where we were" with nothing to render.
165
+ work = dict(state)
166
+ fired_before = set(work.get("effects_log") or {})
167
+ try:
168
+ work = turn(work) # type: ignore[arg-type]
169
+ except Exception:
170
+ # A failed step is not checkpointed. If a write fired before the
171
+ # failure, keep its record instead of raising (see
172
+ # runtime.absorb_step_failure), or a retry fires it again.
173
+ if not absorb_step_failure(work, host, fired_before): # type: ignore[arg-type]
174
+ raise
175
+ state = work # type: ignore[assignment]
176
+ pending = host.drain_pending_messages()
177
+ # runtime.node_updates is the single source of truth for what a
178
+ # flow node checkpoints — including chat_config and the
179
+ # flow_run_id/effects_log at-most-once guard — so this builder and
180
+ # the ReAct-embedded one in integration.py cannot drift apart.
181
+ updates: dict[str, Any] = {
182
+ **node_updates(state),
183
+ "history": state.get("history", []),
184
+ "pending_assistant_messages": pending,
185
+ }
186
+ if pending:
187
+ updates["history"] = list(updates["history"]) + [
188
+ {"role": "assistant", "content": p} for p in pending
189
+ ]
190
+ return updates
191
+
192
+ def wait_node(state: FlowState) -> dict[str, Any]:
193
+ pending = state.get("pending_assistant_messages") or []
194
+ prompt = (
195
+ pending[-1]
196
+ if pending
197
+ else f"Please provide {state.get('awaiting_field', 'input')}."
198
+ )
199
+ user_input = interrupt(
200
+ {
201
+ "prompt": prompt,
202
+ "messages": list(pending),
203
+ "field": state.get("awaiting_field"),
204
+ "flow_id": state.get("flow_id"),
205
+ }
206
+ )
207
+ # Shared routing: dict → structured widget submission (LLM bypass),
208
+ # str → free-form message. See runtime.resume_state_updates.
209
+ return {**resume_state_updates(user_input), "pending_assistant_messages": []}
210
+
211
+ def route(state: FlowState) -> str:
212
+ if state.get("done"):
213
+ return "end"
214
+ if state.get("awaiting_user"):
215
+ return "wait"
216
+ return "end"
217
+
218
+ graph = StateGraph(FlowState)
219
+ graph.add_node("turn", turn_node)
220
+ graph.add_node("wait", wait_node)
221
+ graph.add_edge(START, "turn")
222
+ graph.add_conditional_edges("turn", route, {"wait": "wait", "end": END})
223
+ graph.add_edge("wait", "turn")
224
+
225
+ return graph.compile(
226
+ checkpointer=getattr(host, "checkpointer", None),
227
+ )
228
+
229
+ # ----------------------------------------------------------------- bare runtime
230
+ def turn(self, state: dict[str, Any]) -> dict[str, Any]:
231
+ """Run one user turn, up to the flow's next pause.
232
+
233
+ No LangGraph required — useful for tests and for embedding into any
234
+ framework that doesn't provide a checkpointed graph runtime. The
235
+ caller is responsible for persisting ``state`` between turns.
236
+
237
+ "Up to the next pause" is load-bearing: a step that resumes a
238
+ suspended flow (popping back from a digression) leaves the flow live,
239
+ waiting for nothing and not done. The turn keeps replaying until the
240
+ flow asks the user something or finishes, so the caller always ends a
241
+ turn with something to render.
242
+ """
243
+ state = run_turn(state, self.registry, self.host, mode=self.mode) # type: ignore[arg-type]
244
+ for _ in range(_MAX_CONTINUATION_HOPS):
245
+ if state.get("awaiting_user") or state.get("done") or not state.get("flow_id"):
246
+ break
247
+ # A continuation hop must not re-feed the message this turn already
248
+ # consumed — it would be interpreted a second time.
249
+ state["pending_user_message"] = None
250
+ state["pending_field_updates"] = None
251
+ state = run_turn(state, self.registry, self.host, mode=self.mode) # type: ignore[arg-type]
252
+ return state
253
+
254
+ def session(self, flow: str | None = None) -> "FlowSession":
255
+ """Start a stateful conversation — ``turn()`` without the plumbing.
256
+
257
+ ``flow`` pins the session to one skill (skipping activation). Omit it
258
+ to let the interpreter route the first message.
259
+ """
260
+ return FlowSession(self, flow)
261
+
262
+
263
+ class FlowSession:
264
+ """A conversation with one FlowAgent that reads like a conversation.
265
+
266
+ Wraps the state-dict threading of ``FlowAgent.turn()``:
267
+
268
+ chat = agent.session("transfer-funds")
269
+ chat.user("send $5000 to mom") # free text → interpreted
270
+ chat.submit(verification_code="481920") # widget submit → no LLM at all
271
+
272
+ chat.awaiting # field the flow is paused on (None when not paused)
273
+ chat.spec # that field's FieldSpec — drive YOUR widget from it
274
+ chat.replies # assistant lines produced by the last turn
275
+ chat.done # flow completed?
276
+ chat.state # the underlying FlowState — persist it if you like
277
+
278
+ Both entry points return ``self``, so scripted drives chain:
279
+ ``chat.user("...").submit(confirm=True)``.
280
+ """
281
+
282
+ def __init__(self, agent: FlowAgent, flow: str | None = None) -> None:
283
+ self._agent = agent
284
+ self.state: dict[str, Any] = initial_state()
285
+ if flow is not None:
286
+ agent.registry.get(flow) # fail fast on a typo'd skill name
287
+ self.state["flow_id"] = flow
288
+ self.replies: list[str] = []
289
+
290
+ # ---- the two ways a user acts ------------------------------------
291
+ def user(self, text: str) -> "FlowSession":
292
+ """A free-text user message — interpreted by the LLM into commands."""
293
+ self.state["pending_user_message"] = text
294
+ return self._run()
295
+
296
+ def submit(self, **fields: Any) -> "FlowSession":
297
+ """A structured widget submission — validated in pure Python,
298
+ bypassing the LLM entirely. Resubmitting a field is a correction and
299
+ re-arms any gate that depends on it."""
300
+ self.state["pending_field_updates"] = dict(fields)
301
+ return self._run()
302
+
303
+ def _run(self) -> "FlowSession":
304
+ self.state = self._agent.turn(self.state)
305
+ drain = getattr(self._agent.host, "drain_pending_messages", None)
306
+ self.replies = list(drain()) if callable(drain) else []
307
+ # Both sides of the conversation go in the history (as the LangGraph
308
+ # node does), so "what did you just ask me?" has something to read.
309
+ if self.replies:
310
+ self.state["history"] = list(self.state.get("history") or []) + [
311
+ {"role": "assistant", "content": r} for r in self.replies if r
312
+ ]
313
+ return self
314
+
315
+ # ---- what the UI needs after each turn ---------------------------
316
+ @property
317
+ def awaiting(self) -> str | None:
318
+ return self.state.get("awaiting_field")
319
+
320
+ @property
321
+ def spec(self) -> Any:
322
+ field, flow_id = self.awaiting, self.state.get("flow_id")
323
+ if not field or not flow_id:
324
+ return None
325
+ return self._agent.registry.get(flow_id).fields.get(field)
326
+
327
+ @property
328
+ def approval(self) -> Any:
329
+ """The request this run has waiting with someone else, if it has one
330
+ (``confirm:`` with ``by:``). An ``ApprovalRequest``: who is asked, the
331
+ question, what a yes will run."""
332
+ from .approvals import store_for
333
+
334
+ run = str(self.state.get("flow_run_id") or "")
335
+ return next((r for r in store_for(self._agent.host).for_run(run) if r.status == "pending"), None)
336
+
337
+ def decide(self, approved: bool, *, by: str = "", note: str = "") -> "FlowSession":
338
+ """The other person answers, and the flow takes its next turn. For tests
339
+ and scripts: in a deployment the answer comes from wherever requests are
340
+ shown to the people who decide them."""
341
+ from .approvals import store_for
342
+
343
+ request = self.approval
344
+ if request is None:
345
+ raise RuntimeError("nothing is waiting for someone else's yes")
346
+ from .runtime import LOOK_IN
347
+
348
+ store_for(self._agent.host).decide(request.id, approved, by=by, note=note)
349
+ self.state["pending_field_updates"] = {LOOK_IN: request.id}
350
+ return self._run()
351
+
352
+ @property
353
+ def widget(self) -> dict[str, Any] | None:
354
+ """The widget the flow built for this pause, when it built one: a list
355
+ to pick from, a designed widget with its data, or the card a
356
+ confirmation shows (which tool a yes will run, and with which inputs)."""
357
+ return self.state.get("pending_widget")
358
+
359
+ @property
360
+ def done(self) -> bool:
361
+ return bool(self.state.get("done"))
File without changes