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.
- aigenos_flow/__init__.py +152 -0
- aigenos_flow/_server_entry.py +18 -0
- aigenos_flow/_slack_helper.py +96 -0
- aigenos_flow/agent.py +361 -0
- aigenos_flow/agui/__init__.py +0 -0
- aigenos_flow/agui/events.py +135 -0
- aigenos_flow/agui/protocols/__init__.py +42 -0
- aigenos_flow/agui/protocols/a2ui.py +89 -0
- aigenos_flow/agui/protocols/mcp_ui.py +149 -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/llm.py +143 -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/approvals.py +135 -0
- aigenos_flow/autowidget.py +346 -0
- aigenos_flow/connectors/__init__.py +30 -0
- aigenos_flow/connectors/api.py +322 -0
- aigenos_flow/connectors/manager.py +532 -0
- aigenos_flow/connectors/models.py +122 -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 +475 -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/widgets/trip_checkout.json +75 -0
- aigenos_flow/data/workflows/refund_flow.yaml +63 -0
- aigenos_flow/declarative.py +1298 -0
- aigenos_flow/decorators.py +132 -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 +206 -0
- aigenos_flow/fields.py +201 -0
- aigenos_flow/guarantees.py +340 -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 +1432 -0
- aigenos_flow/integrations/__init__.py +0 -0
- aigenos_flow/integrations/databases.py +332 -0
- aigenos_flow/integrations/flow.py +2243 -0
- aigenos_flow/integrations/langgraph.py +530 -0
- aigenos_flow/integrations/write_access.py +28 -0
- aigenos_flow/interpret.py +1815 -0
- aigenos_flow/main.py +628 -0
- aigenos_flow/mcp_tools.py +226 -0
- aigenos_flow/outline.py +296 -0
- aigenos_flow/procedure.py +676 -0
- aigenos_flow/py.typed +0 -0
- aigenos_flow/runtime.py +1936 -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/shortform.py +1473 -0
- aigenos_flow/skill_editor.py +222 -0
- aigenos_flow/skill_generator.py +959 -0
- aigenos_flow/skills.py +2190 -0
- aigenos_flow/store.py +197 -0
- aigenos_flow/testing.py +143 -0
- aigenos_flow/toolbox.py +643 -0
- aigenos_flow/trace.py +84 -0
- aigenos_flow/write_guard.py +144 -0
- aigenos_flow-0.3.1.dist-info/METADATA +414 -0
- aigenos_flow-0.3.1.dist-info/RECORD +82 -0
- aigenos_flow-0.3.1.dist-info/WHEEL +4 -0
- aigenos_flow-0.3.1.dist-info/entry_points.txt +2 -0
- aigenos_flow-0.3.1.dist-info/licenses/LICENSE +202 -0
- aigenos_flow-0.3.1.dist-info/licenses/NOTICE +5 -0
aigenos_flow/__init__.py
ADDED
|
@@ -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
|