Substitution experiment: the agentic demo with and without Flowgentic¶
This report is the evidence for decision D1: keep the LangGraph execution
adapter (LangraphIntegration and its asyncflow decorators) for v0.4.0, or
freeze it. It runs one agentic application twice, once through Flowgentic and
once on AsyncFlow directly, and records what the adapter demonstrably
supplies. A capability counts for the adapter only when the adapter supplies
it and the direct version does not.
Outcome (October 2026): following this report, the execution adapter is frozen in maintenance mode. See Project status.
Setup¶
| Flowgentic version | Direct version | |
|---|---|---|
| Application | examples/ai-hpc-coupling/agentic_application.py |
examples/ai-hpc-coupling/direct_agentic_application.py |
| Integration | LangraphIntegration context; asyncflow decorators (SERVICE_TASK, AGENT_TOOL_AS_FUNCTION, FUNCTION_TASK, EXECUTION_BLOCK); introspected nodes |
WorkflowEngine.create and shutdown; flow.function_task (service=True for the surrogate); flow.block; langchain_core.tools.tool |
| Runner | agentic_campaign.py --integration flowgentic |
agentic_campaign.py --integration direct |
The LangGraph graph, node bodies, tools, state and policy guards are the same
in both versions. A diff of the two application modules shows only the
integration layer, plus the control loop: it stays in agentic_application.py,
and the runner uses it for both versions. Both use the same decision model,
budget, backends (local ai and compute), simulation retry policy, service
lifetime and stopping rules. The runner builds the model, settings and
backends once; open_campaign in agentic_campaign.py gives both versions the
same engine and service lifetime; and run_agentic_with_application_control
applies the same stopping rules.
Versions: Flowgentic 0.3.0 (release/v0.3.0, merged into main as
9949f5e7), radical-asyncflow 0.6.0,
LangGraph 1.2.16, langchain-core 1.6.13, langchain 1.4.7, Python 3.10. R5 first
ran on AsyncFlow 0.5.0 and LangGraph 0.6.11. R8 moved the lock to the versions
above, and every test and number in this report was rerun on them with the same
results. Decisions come from the
offline rehearsal model (--agent-mode rehearsal), with failure injection on:
the first simulation of cycle 1 fails once and is retried.
Evidence is the test module tests/integration/test_substitution.py, which CI
runs with the rest of tests/; locally,
uv run --extra dev pytest -q tests/integration/test_substitution.py.
Its telemetry tests are opt-in (see row 7). Anything not backed by a test is
marked as code reading or as an observation from a demo run.
Retry policies (amendment A1). AsyncFlow has no per-task retry or timeout, in
0.6.0 as in 0.5.0. The direct version reproduces only the demo's explicit simulation
policy, SIMULATION_RETRY in demo_support.py (2 attempts, 0.05 s back-off,
5 s per attempt), by reusing retry_transient_simulation from
campaign_common.py, which the RADICAL baseline already uses. Flowgentic also
wraps every other call, graph nodes included, in its default RetryConfig
(3 attempts, 30 s per attempt, retrying TimeoutError, ConnectionError,
OSError and some httpx and aiohttp errors). Row 3 reports what those
implicit defaults do.
Results¶
| # | Criterion | Flowgentic version | Direct version | Verdict | Evidence |
|---|---|---|---|---|---|
| 1 | Results and scientific state transitions | 5 cycles, 20 simulations, 1 retry, stop on the uncertainty goal (0.2013), best x 2.76961 | Identical | Both supply it | test_flowgentic_and_direct_versions_match: the two campaign_summary.json files are equal once timing fields are dropped. That covers results, the 45-event sequence, the 15 agent decisions with their policy statuses, and the service counters. |
| 2 | Task and invocation identity | AsyncFlow assigns each task and block a uid and records its function name and target backend. The Flowgentic report adds node records keyed by node name and timestamp, not linked to AsyncFlow uids. | AsyncFlow uids, names and target backends | Both supply it | test_both_versions_submit_the_same_asyncflow_components: a one-cycle run registers the same 14 components in both versions, each under its own uid: 13 per cycle (4 node blocks and 9 tasks: query_surrogate, reason_about_plan, 4 × run_simulation, update_surrogate, reason_about_evidence, coordinate_agents) plus the one-off load_surrogate service. |
| 3 | Failure, retry and cancellation | Explicit simulation retry via RetryConfig. Implicit retry and timeout (3 attempts × 30 s) on every call and graph node. |
Explicit simulation retry via retry_transient_simulation; no implicit retry or timeout |
Explicit retry and cancellation: both supply it. Implicit call and node retry and timeout: the adapter supplies it, and here it is harmful. | The explicit retry works in both: 1 retry each in test_flowgentic_and_direct_versions_match. Cancelling a cycle cancels its in-flight simulations in both: test_cancelling_a_cycle_cancels_inflight_simulations. The implicit defaults re-run scientific work. With a simulation that keeps failing, Flowgentic makes 24 attempts where the direct version makes 8, because the node retry re-runs the whole batch (test_persistent_simulation_failure). In test_node_exceeding_the_default_timeout_is_rerun, the 30 s default is scaled down to 0.3 s: a node that outlasts it is cancelled and re-run with its simulations (12 starts against 4), and after three attempts the Flowgentic cycle fails with a timeout error (asyncio.TimeoutError on Python 3.10), while the direct version completes. With the real default, the same code path fails any graph node whose work takes longer than 30 s. Each version brings its own per-task retry (the adapter's RetryConfig, the direct version's retry_transient_simulation), because AsyncFlow (0.5.0 and 0.6.0) has none. |
| 4 | Service reuse and cleanup | AsyncFlow service=True task. The integration context, entered on the runner's AsyncExitStack, shuts the engine down. |
AsyncFlow service=True task. An AsyncExitStack callback shuts the engine down. |
Both supply it | test_flowgentic_and_direct_versions_match: one service load and identical request and update counts (5 and 5) in both, both runs exit 0, and neither stderr has a "never retrieved" or "destroyed but it is pending" warning. No test asserts the shutdown itself. Cancelling the resident service is application code in both (open_campaign). |
| 5 | Resource and token accounting | Tasks run on the named backends; observed compute parallelism 4. The node report sums tokens from message metadata. | Same placement and parallelism | Resources: both supply it. Tokens: not tested. | test_both_versions_place_tasks_on_the_named_backends: each backend's own task registry shows 4 × run_simulation on compute and the six AI tasks on ai, in both versions. Parallelism comes from test_flowgentic_and_direct_versions_match. Tokens need a live LLM run, and none was possible without credentials. Code reading says a live run would record 0 tokens in both versions anyway: decision_message in agentic_support.py sets usage to zero, and LangChainDecisionModel._decide calls with_structured_output, which returns the parsed decision rather than the message that carries usage. Recording tokens is a job for the application's decision-model layer; Flowgentic's report can only sum what the messages carry. |
| 6 | Application code and maintenance | Application module without the shared control loop: 362 lines (319 code lines). The adapter is 596 lines (main.py, execution_wrappers.py, fault_tolerance.py). LangraphIntegration also creates a memory manager (memory.py, 723 lines) that this demo never uses. |
Application module: 378 lines (323 code lines), including 32 lines (22 code) of local helpers (asyncflow_tool, asyncflow_block) |
Both supply it, in about the same code: the direct version has 4 more code lines. The adapter adds code and dependencies to maintain. | Line counts are from wc -l at 9949f5e7, plus a count of non-blank, non-comment, non-docstring lines. The documentation later added snippet-marker comments (12 lines to agentic_application.py, 8 to direct_agentic_application.py), which change the wc -l totals but not the code-line counts. The Flowgentic module's control loop (run_agentic_with_application_control, 16 code lines) is left out because both versions use it. To offer both versions, the runner gained 76 lines and lost 20. direct_agentic_application.py imports nothing from Flowgentic. A direct run in this repository still loads Flowgentic through shared code: the runner imports LangraphIntegration and agentic_application.py, and agentic_support.py imports demo_support.py, which imports Flowgentic. A standalone direct application would need none of it. Dependencies (fresh resolve, Python 3.10): Flowgentic's come to 74 third-party packages, the direct version's (radical-asyncflow==0.6.0, langgraph>=1.0,<2.0, langchain-core>=1.0,<2.0) to 42. That is 32 more, plus Flowgentic itself, among them langchain, langchain-openai, langchain-ollama, academy-py and python-json-logger. Live mode needs a provider package (for example langchain-openai) in either version. See also the LangGraph 1.x port below. |
| 7 | Observability and reporting | agent_execution_results/execution_summary.md: per-node duration, state keys, messages and model names |
No report unless the application wires in Flowgentic's telemetry module | Both supply it. The node-level report comes from Flowgentic's telemetry module, which works without the execution adapter; task-level spans come from AsyncFlow (opt-in). | test_flowgentic_and_direct_versions_match: only the Flowgentic run writes the report. test_flowgentic_report_works_without_the_adapter: Flowgentic's GraphIntrospector (flowgentic.utils.telemetry), wrapped around the direct version's nodes, produces the node-level report with no LangraphIntegration. The application has to wrap the nodes and set the introspector's private _all_nodes and _final_state; the Flowgentic version does the same through add_instrumented_nodes and the runner. Observed in a demo run's report: it shows 0 tool calls and 0 tool executions, because node code calls the tools directly. AsyncFlow 0.6.0 emits OpenTelemetry spans once flow.start_telemetry() runs with RHAPSODY's TelemetryManager installed (rhapsody-py[telemetry], a dependency of neither AsyncFlow nor Flowgentic). test_asyncflow_telemetry_spans is skipped unless RHAPSODY is installed; it was run locally with rhapsody-py[telemetry]==0.6.0. In both versions it asserts 1 workflow span, 4 block spans and 9 task spans per cycle, with each task span carrying task_id, executable and status. It also asserts that every task span is labelled with the default backend (compute), including the AI tasks, which row 5 shows ran on ai. That mislabelling is an AsyncFlow defect, present in 0.5.0 and 0.6.0. |
| 8 | LLM tool selection (R5b) | A scripted model's call to run_simulation, an AGENT_TOOL_AS_FUNCTION tool, runs through LangGraph's ToolNode as an AsyncFlow task on compute |
The same, with the AsyncFlow task wrapped by langchain_core.tools.tool through the direct version's asyncflow_tool helper |
Both supply it | test_llm_selected_tool_runs_as_an_asyncflow_task: in both versions the graph produces a human message, the model's tool call, a successful tool message with identical content, and the model's answer. AsyncFlow registers exactly one run_simulation task on compute per version. The model is scripted (GenericFakeChatModel), so this tests the tool path, not a live model's choice. |
| 9 | Agent handoff (R5b) | create_task_description_handoff_tool builds a tool that runs an AsyncFlow task returning Command(goto=[Send(...)], graph=Command.PARENT). On main since 09b66146 (2025-10-16), it failed on every call: it called GraphIntrospector.record_supervisor_event, which that commit deleted. R5b removed the call. The code review of R5b then found that the tool also failed on a TypedDict state (MessagesState, or create_agent's), because it called model_dump(); it now accepts both. |
A 17-line tool, in the test module, that builds the same Command in an AsyncFlow task |
Both supply it after R5b's fix; before the fix, only the direct version did. | test_supervisor_hands_off_to_a_worker, for both versions and for both a Pydantic and a TypedDict graph state: a supervisor subgraph's scripted tool call hands the task to the parent graph's researcher node, which receives exactly the task description, and the handoff runs as one AsyncFlow task. Before the fix, the Flowgentic case failed with AttributeError: 'GraphIntrospector' object has no attribute 'record_supervisor_event' (the failing run is recorded in PR B's body), and the direct case passed. |
Changed in 0.4.0 (2026-10-24): row 3
Row 3 records 0.3.0. Flowgentic 0.4.0 removes the implicit retry and
timeout, and its version of the demo wraps each simulation call in
retry_transient_simulation, as the direct version does. With a simulation
that keeps failing, both versions now make 8 attempts
(test_persistent_simulation_failure). A node whose work outlasts the
scaled-down timeout of 0.3.0 runs once and completes in both versions, with
4 simulation starts (test_slow_node_runs_once, which replaces
test_node_exceeding_the_default_timeout_is_rerun). See
Retries and timeouts.
Changed in 0.4.0 (2026-10-25): rows 5 and 7
Rows 5 and 7 record 0.3.0. Flowgentic 0.4.0 removes the node report and
records a run as OpenTelemetry spans; see
Telemetry.
test_asyncflow_telemetry_spans now runs in CI, because the dev extra
installs RHAPSODY.
Tokens:
- The demo no longer writes zero usage on its messages.
- In a live run, each model call's chat span carries the usage the
provider reported, recorded by OpenTelemetry's GenAI instrumentation for
LangChain. It reads usage in LangChain's callbacks, so
with_structured_output no longer hides it
(test_decision_task_and_model_call_form_one_trace).
Both versions get these spans from the same instrumentation and from
RHAPSODY. What is distinct to Flowgentic is the trace context it carries
into every task it submits. A model call inside one of the Flowgentic
version's tasks stays in the run's trace
(test_a_task_runs_in_its_callers_trace_context, which failed on main
with an empty context). The direct version calls AsyncFlow itself, and
needs the same code for the same result.
Changed in 0.4.0 (2026-10-25): rows 1 and 6, the planner's acceptance
Rows 1 and 6 record 0.3.0, where both versions' planners called
enforce_candidate_policy: one proposal, checked and silently replaced by
the surrogate's batch if it failed. In 0.4.0 the Flowgentic version's
planner calls accept instead, with three
checks, one re-ask and the surrogate's batch as the fallback. The direct
version writes the same policy by hand.
Same decisions: test_both_versions_accept_planner_proposals_alike runs
one cycle of each version with a scripted planner that proposes outside the
bounds. If the planner corrects itself once told why, both versions accept
its second proposal. If it never does, both run the fallback after two
rejections. In both cases the two versions record the same decision,
disposition, rejected proposals, re-ask feedback, events and simulated
batch. test_flowgentic_and_direct_versions_match still finds the
rehearsal runs equal, with every planner decision accepted at the first
attempt.
Code: the planner node is 76 code lines in the Flowgentic version and 79 in
the direct one, both 57 before. accept replaces the direct version's
loop, but its producer function and the conversion of its attempts into
the agent trace's records take nearly as many lines. In this demo it saves
3 code lines. Line counts are of non-blank, non-comment, non-docstring
lines.
What accept supplies and the hand-written loop does not:
- The record in the trace. Each planner decision is an accept planner
span, with an accept.attempt planner span per proposal and a
gen_ai.evaluation.result event per check that ran. The direct version
records none of them.
- Attempts that the producer cannot share by mistake. The first version of
the direct loop passed its own list of rejections into the planner's
task. A task on a thread backend shares what it is given, so a later
rejection showed up in an earlier re-ask's feedback. The equality test
caught it, and the loop now passes a copy. accept hands each attempt a
new tuple.
Row 6 says that direct_agentic_application.py imports nothing from
Flowgentic. It now imports Rejected, which the demo's shared checks and
decision model raise. A standalone direct application would define its
own exception.
LangGraph 1.x and AsyncFlow 0.6.0 (amendment A4, R8)¶
Row 6 counts the LangGraph 1.x port as a maintenance cost. R5 first estimated it in a scratch environment, where the full suite passed unchanged on LangGraph 1.2.16. D10 then moved the port into PR B as R8. The port took:
- Pins:
langgraph>=1.0,<2.0,langchain-core>=1.0,<2.0,langchain>=1.0,<2.0andradical-asyncflow==0.6.0. The relock upgraded only these packages and the LangChain providers, but moved 27 lock entries in all, among them langchain-openai 1.7.2, langchain-ollama 1.1.0, openai 3.36.1, langsmith 0.14.12 and langgraph-checkpoint 4.3.0. The floors were raised to 1.0, so no CI job on a 0.6 lower bound is needed; the locked CI matrix is the 1.x job. - Flowgentic source: deleting the two unused
create_react_agentimports, inlangGraph/execution_wrappers.pyandlangGraph/utils/supervisor.py. Nothing else changed.Command,Send,StateGraph, checkpointers,ToolNodeandInjectedStatekeep their APIs in 1.x;ToolNode's default error handling changed (below). - Examples: the 9
create_react_agentcalls (4 files) moved tolangchain.agents.create_agent, which the LangGraph v1 migration guide names as the replacement. They passed onlymodelandtools, so no argument changed. The example files also importedConcurrentExecutionBackend, which AsyncFlow 0.1.0 exported but 0.3.0 and later do not. They broke when172deb8f(2026-08-18, ondemo/radical) moved the lock from AsyncFlow 0.1.0 to 0.5.0. R8 renamed it toLocalExecutionBackend, and the 7 LangGraph example modules import again; that fixes an existing break rather than being a cost of the port. - Result at R8: 40 passed, 4 skipped on the new lock, with no warnings; with R5b's tests and its code review, 48 passed, 4 skipped. The substitution tests and the opt-in telemetry tests give the same results as on LangGraph 0.6.11 and AsyncFlow 0.5.0.
- Behaviour change: LangGraph 1.x's
ToolNodere-raises tool errors by default, where 0.6 returned them to the model as messages. A Flowgentic tool that still fails after its retries now aborts the graph run, unless the application passeshandle_tool_errors=True.
AsyncFlow 0.6.0 still has no per-task retry or timeout, and its spans still
label every task with the default backend. The design-pattern pages now
include the examples' code, so they show create_agent.
Not tested (amendment A3)¶
The agentic demo calls its three tools (query_surrogate, run_simulation,
update_surrogate) from node code with .ainvoke, and its three reasoning
functions as plain AsyncFlow tasks. R5b's scripted tests (rows 8 and 9) cover
LLM tool selection and handoffs outside the demo. These adapter features
remain unexercised; they are unevaluated, not failed:
- Memory: the integration creates a
LangraphMemoryManager, which this demo never uses. - The mutable graph (
langGraph/mutable_graph.py). Its example has run in CI since 0.3.0's documentation update (tests/integration/test_feature_examples.py), outside this experiment. - Live LLM decisions, and with them token accounting (row 5).
- ADR control (
--controller adr): unsupported in 0.3.0 (D6). It ran locally in both versions against an ADR checkout, but no test covers it. - Remote or HPC backends: both versions ran on local backends only.
Changed in 0.4.0 (2026-10-27): row 6 and this list
Row 6 and this list record 0.3.0. Flowgentic 0.4.0 removes the memory
manager and the mutable graph, so LangraphIntegration no longer creates a
memory manager and neither feature is left to evaluate. See the
changelog.
Summary for D1¶
In this application, the execution adapter's only distinct contribution is its implicit retry and timeout on every call and graph node. The tests show it re-running scientific work, and failing any node that outlasts the per-attempt timeout (30 s by default).
The node-level report comes from Flowgentic's telemetry module, which also works on the direct version without the adapter. Everything else is supplied equally, in about the same application code (the direct version has 4 more code lines in the demo) and with 32 fewer third-party packages: results, task identity, the explicit retry, cancellation, service reuse, cleanup, backend placement, LLM tool selection and handoffs. One convenience is the adapter's: a direct handoff needs a 17-line tool where Flowgentic needs one call.
The adapter's handoff tool had failed on every call since October 2025, when a refactor deleted the introspection method it called. PR B removes the call; no test had covered the path. Neither version records tokens, and by code reading the gap is in the application's decision-model layer.
Design recommendation: per-task retry and timeout belong in AsyncFlow, which
has neither in 0.6.0, so today each application writes its own. The move to
LangGraph 1.x and AsyncFlow 0.6.0 (R8) cost two deleted imports in Flowgentic,
a rename in 9 example calls, and one behaviour change that applications must
handle: ToolNode now re-raises tool errors by default. The provider packages
also moved a major version (langchain-openai 1.x, openai 3.x); offline tests
now check that the providers still build.
Changed in 0.4.0 (2026-10-24): the summary
Flowgentic 0.4.0 removes the implicit retry and timeout. Graph nodes take
LangGraph's own retry_policy and timeout, and task retries stay with the
application until AsyncFlow has them. In this application, what remains
distinct to the adapter is the one-call handoff tool.