Commit Graph
1721 Commits
Author SHA1 Message Date
Charles YaoandClaude Opus 5 e54a2cbd2a fix(dify-builder): ground only credentials and host endpoints, never data inputs
The test form and mock data skipped every start variable an http node
read, so a search query or a language header was never mocked. Only a
host-position URL template, credential-keyed header/param lines and an
api-key auth now count, with one shared is_credential_key in core. A
bare "key" param, an empty credential and a bare "Bearer" are grounded
too, and an existing Dify template is never re-grounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:43:00 -07:00
Charles YaoandClaude Opus 5 e741ce902b fix(dify-builder): a credential the user didn't supply becomes an input
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:22:46 -07:00
Charles YaoandClaude Opus 5 ea46a69040 fix(dify-builder): an endpoint the user didn't supply becomes an input
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:07:02 -07:00
Charles YaoandClaude Opus 5 a73c443ecd fix(dify-builder): scrub only real credential keys and exact URL hosts
Keys are credentials by their last segment (api_key, access_token,
Authorization), not by containing "token" or "password", so
password_policy / token_limit defaults survive. URL hosts stop at the
first non-URL character and drop trailing punctuation, so a goal that
names a host at the end of a sentence or next to CJK text is recognised.
Placeholder tokens like <…> only count under a credential key or an
auth scheme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:52:37 -07:00
Charles YaoandClaude Opus 5 be24b00a2a fix(dify-builder): requirements analysis stops inventing endpoints and credentials
S5b (local E2E 2026-09-22, F2): analyze_goal's "a sensible default value per
field" led the LLM to prefill render_api_url=https://api.yourcompany.com/...
and Authorization: Bearer YOUR_API_KEY for a goal that named neither -- caught
only because "yourcompany" happened to match an existing placeholder-URL
regex; a plausible invented host would slip through and recreate ESQ1-302's
launch failure one step upstream, in the requirements form itself.

Adds services/dify_builder/agent/user_supplied.py (pure, no I/O): url_hosts,
is_user_supplied_url, CREDENTIAL_PLACEHOLDER_RE, is_user_supplied_secret,
trusted_text_for -- shared primitives for telling a literal the user actually
wrote from one an LLM invented, used by this task and consumed by later
tasks in the same plan.

build.analyze_goal now tells the model never to invent such a field's
default, and _scrub_invented_defaults deterministically blanks any top-level
field whose value contains a URL host, or a credential-shaped or
credential-keyed literal, not present in the goal text -- decided on hosts
and literal text, never on LLM prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:40:33 -07:00
Charles YaoandClaude Opus 5 fc0f5cf8fd test(dify-builder): real resource shapes and placeholder-endpoint coverage
- bind_resources fixtures use the production model label, the full
  provider/model id (langgenius/tokener/tokener/deepseek-v4-flash), and the
  real Code Interpreter id (code/simple_code); the docstring's ESQ1-302 quote
  now shows that shape too.
- The run_test prefill uses without_endpoint_values instead of re-evaluating
  endpoint_variable_names(graph) for every key.
- _ground_placeholder_endpoints: two placeholder http-request nodes get two
  start variables, and a start variable that already exists is not duplicated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 03:02:35 -07:00
Charles YaoandClaude Opus 5 21aff66f45 fix(dify-builder): bind a tool by its id and pin it in the generator
The planner is told to name a tool by its id, but binding matched only the
label and then the "tool" keyword, so two tool steps naming ids both bound
to the first tool step. _covering_step now tries the resource id before the
label. A tool's "(using ...)" suffix also carries its provider/tool id in
ASCII brackets: the shared generator pins a tool only on that identifier
(tool_catalogue._find_explicit_tool_keys), never on the label. The planner
prompt asks for the id as a separate word, since a CJK-glued id never pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:56:46 -07:00
Charles YaoandClaude Opus 5 b1ff7c1596 fix(dify-builder): match resource labels and node types as whole words
"tool" matched "toolkit" and "llm" matched "fulfillment", binding a
resource to an unrelated step instead of falling back. Labels and
keywords now match case-insensitively on ASCII-alphanumeric boundaries,
which still matches CJK step text such as "llm节点".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:35:16 -07:00
Charles YaoandClaude Opus 5 763f93ebc9 fix(dify-builder): re-binding resources replaces the old suffixes
A loop-back feeds the already-bound plan back into bind_resources,
which only appended: a deselected resource kept its "(using …)" and
suffixes piled up in the text handed to the generator. Trailing
"(using …)" groups are stripped before binding, so a re-bind reflects
exactly the current selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:33:46 -07:00
Charles YaoandClaude Opus 5 681f92557d fix(dify-builder): bind each resource to the plan step it covers
bind_resources appended every confirmed resource's label to the LAST
plan step: the ESQ1-302 end-node step read "(using deepseek-v4-flash,
Code Interpreter)" while the step that needed a tool said nothing, and
that is the text the generator was handed. Each resource now lands on
the first step naming its label, else the first step naming its kind's
node type (llm / knowledge-retrieval / tool); the last step is only the
fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:22:32 -07:00
Charles YaoandClaude Opus 5 da338dd5fd fix(dify-builder): only a localhost host marks an endpoint as a placeholder
_PLACEHOLDER_URL_RE matched "localhost" as a substring anywhere in the
URL, so a real tunnel host like abc123.localhost.run was wrongly
flagged; the check now parses the URL and only matches when the host
itself is localhost or ends with .localhost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:16:28 -07:00
Charles YaoandClaude Opus 5 e4a30b7be1 feat(dify-builder): a placeholder endpoint becomes a required start variable
The ESQ1-302 draft called https://api.example.com/ppt/generate. The
model had no endpoint, so it invented one, and every test run failed
against a host that does not exist. build_nodes now re-points an
http-request whose URL is empty or a placeholder (example.com,
your-api, <your-domain>, {tenant}, localhost) at a new REQUIRED
start-node variable "<node>_url", so the test-data gate asks the user
for the real endpoint -- or, when they have none, says what the step
needs. A Dify template reference is not a placeholder.

Builder-only: the shared http-request snippet's own example is
https://example.com, and cmd+K keeps its placeholders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:00:19 -07:00
Charles YaoandClaude Opus 5 6bd32a733e feat(dify-builder): show the planner the tenant's ready tools
propose_plan_v1 was shown REQUIREMENTS only. It cannot plan a tool step
for a tool it has never seen, so the ESQ1-302 plan said "http-request
节点:调用外部PPT生成API" while the tenant had a ready Markdown -> PPTX
tool -- and every later stage (resource recommendation, the shared
generator, the directive) took the plan's word for it.

ready_tool_catalogue lists the tenant's READY tools (an installed-but-
unauthorized tool cannot cover a step), sorted and capped at 40; the
planner prompt gains an "Installed tools you may name" section and tells
the planner to plan an http-request only for an endpoint the user
actually supplied. A listing failure degrades to the old no-catalogue
prompt, never a crashed advance. The positional signature is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:52:01 -07:00
Charles YaoandClaude Opus 5 493d40591a fix(dify-builder): the directive no longer steers away from installed tools
fe3f8154b5 told the generator to use a tool node "only when the plan
names an installed tool; otherwise use 'http-request'". That contradicts
the shared planner prompt (INSTALLED-TOOL-FIRST), and the Builder's own
planner was never shown the tool catalogue, so it could not name one:
every third-party step fell through to an http-request with an invented
endpoint (ESQ1-302 called https://api.example.com/ppt/generate while
the tenant had a ready Markdown -> PPTX tool).

The rule now says tool-first, forbids invented and placeholder
endpoints outright, and keeps the half that earned its place (ESQ1-286):
an http-request's changing inputs belong on the start node.

Builder-only: nothing under core/workflow/generator/ changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:45:05 -07:00
Charles YaoandClaude Opus 5 9218d1583e fix(dify-builder): record intents a first build drops on a partial reject
build_nodes dry-runs the generator's intents through filter_applicable
and recorded the rejections only when NOTHING applied. A partial reject
-- e.g. a connect on a branch handle the node does not declare, one the
generator postprocess could not re-home and apply_connect now refuses --
kept the applicable intents and dropped the rest with no log line and no
diagnostic, leaving that arm unwired on the canvas without a trace.

A partial reject now logs a warning naming each dropped op, its args
(minus a create_node's config body) and the reason, and appends a
build_nodes diagnostic with the same list; the applicable intents are
still returned. The total-reject path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:29:43 -07:00
Charles YaoandClaude Opus 5 197cd9c5ad fix(dify-builder): teach Edit branch handles and retry a partial reject
apply_connect now rejects a connect from a branch node on a handle it
does not declare. Edit's prompt never mentioned source_handle and its
graph view showed no handles, so such connects defaulted to "source"
and a partial reject was dropped without a retry. The op schema and
graph view now carry the handles, and any rejection gets one corrective
re-prompt whose feedback names the declared handles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:43:43 -07:00
Charles YaoandClaude Opus 5 cf99766d93 fix(dify-builder): count nodes the chokepoint normalizers healed as changed
apply_repair's value normalizers can heal a node the repair's intents
never touched; the diff showed it but changed_nodes did not. Both
normalizers' changed ids now join changed_nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:43:35 -07:00
Charles YaoandClaude Opus 5 d299a81a9f fix(dify-builder): normalize values at the write chokepoint; reject undeclared handles
apply_repair runs the same graph_normalizers the generator's postprocess
runs, for the intents the generator never saw: a Fix/Edit set_node_config
that writes a condition value as a JSON number (ESQ1-285's flip-flop) or
an http body item without type is healed before the preflight would
reject it.

apply_connect rejects an edge from a branch node on a handle the node
does not declare -- including the default "source" it used to fill in
-- with the same ValueError the handlers already surface as "Couldn't
apply the fix". Such an edge hangs off nothing and its arm never runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:27:44 -07:00
Charles YaoandClaude Opus 5 b7017aa363 fix(dify-builder): tell a draft that would not start apart from a stale fix
apply_repair's preflight raises PreflightError when the graph it is about
to write would fail at Graph.init. Every handler caught it as a plain
ValueError, so the five apply sites could not tell it apart from a
graph_ops rejection:

- plan approval (Build, Edit) called ANY ValueError "The workflow can't
  start", including "node not found: x", sending the user hunting for a
  broken node that does not exist;
- the repair-apply gates (Build/Edit await_repair, Fix apply) called a
  preflight rejection "The proposed fix no longer applies to the current
  draft" -- the fix applied fine; it would leave an unstartable draft.

Add core DraftWouldNotStartError(ValueError) and make PreflightError
subclass it (services imports core; core still imports no services).
Each site now catches it before the ValueError branch, with the same
control flow and the true reason: plan approval keeps "The workflow
can't start" for it and says "Couldn't apply the workflow" otherwise;
the repair gates say "The workflow can't start" for it and keep
"Couldn't apply the fix" otherwise. The shared card/turn construction is
factored (_graph_not_applied, _change_not_applied, and
drop_unapplied_repair in handlers_fix) and the new literals are
catalogued in strings.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:57:33 -07:00
Charles YaoandClaude Opus 5 dd11872222 fix(dify-builder): stop re-running after two consecutive unknown outcomes
The unknown-outcome branch (a stream that ended with neither a terminal
nor an error frame) returned the user to the re-run state with no
limit in all three flows. With error frames now reported as failures
that branch is rare, but a stream that keeps ending early could still be
re-run forever. DifyBuilderContext.unknown_outcome_count counts
consecutive unknown outcomes (reset by any determinate one); the second
in a row stops at the gate with a card telling the user to look at the
run itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:10:10 -07:00
Charles YaoandClaude Opus 5 fcb7c719ef feat(dify-builder): preflight the draft in apply_repair before writing it
ESQ1-302 and ESQ1-303 both died at Graph.init -- before workflow_started,
so the Builder saw an error frame with no run id and no rows, and spent
runs and approvals discovering what a validation pass could have said
up front. apply_repair now runs that pass (preflight_errors: the same
node-data validation create_node performs, with no factory or runtime
state) over the graph it is about to write and raises PreflightError,
naming each node Graph.init would reject.

Only NEW problems count: a pre-existing broken node the repair does not
touch must not veto an unrelated fix, and a repair that heals it passes.
PreflightError is a ValueError so the handlers' existing stale-intent
guards already surface it as a card.

The real ESQ1-303 dev draft is the fixture: preflight names node2 and
its numeric value, and passes once the value is a string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:21:17 -07:00
Charles YaoandClaude Opus 5 f862fe327e fix(dify-builder): a stream that ends on an error frame is a failed run
run_draft forwarded the pipeline's ``event: error`` frame to the frontend
and then dropped it, so the ESQ1-302 launch failure (Graph.init rejected
an http-request node before workflow_started) fell into the
truncated-stream path and was reported as "running". The handler's
unknown-outcome branch then sent the user back to re-run, forever.

The loop now keeps the first error frame aside and _finish_run applies
one precedence: terminal frame > error frame > nothing. Chatflow emits
workflow_finished(failed) AND an error frame for one failure, so the
terminal frame must win; a stream that ends with neither still reads as
an unknown outcome, never as a fabricated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:51:24 -07:00
Charles YaoandClaude Opus 5 d067b95bce fix(dify-builder): map an error frame to a failed Run with its reason
The streaming pipeline reports a run that threw as one ``event: error``
frame with top-level message/code and no data (ESQ1-302: Graph.init
rejected a node before workflow_started, so there is no run id either).
run_mapping had no representation for it: _TERMINAL_STREAM_EVENTS lists
only workflow_finished/paused, so the frame was neither terminal nor a
node event and the run came back "running" with the truncated-stream
notice.

error_from_stream_chunk reads such a frame; map_error_frame_run turns it
into a FAILED Run carrying the message (whitespace collapsed, so it is a
stable repair-breaker signature) and the code. map_run_result now keeps
the engine's run-level error text for a failed run instead of dropping
it -- a launch failure has no per-node rows, so that text is the only
evidence diagnose and the breaker have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:45:26 -07:00
twwu 9acf99060b fix(dify-builder): hide redundant send goal action 2026-09-21 17:25:50 +08:00
twwu d08c189614 Merge branch 'main' into feat/dify-builder 2026-09-21 16:31:28 +08:00
twwu d9991a11e1 Merge branch 'main' into feat/dify-builder 2026-09-21 15:23:36 +08:00
twwu d5ca5ea8ef fix(dify-builder): stream native test runs after canvas refresh
Run workflow streaming inside the Builder worker to avoid queue deadlocks. Forward native events to shared editor callbacks and wait for graph refresh before retesting repairs.

Keep command cleanup stable, preserve batched canvas events, and allow failed or unfinished historical runs to refresh again.
2026-09-21 15:12:22 +08:00
zyssyz123 af4f19840c feat(agent): record E2B sandbox runtime usage (#42554) 2026-09-21 07:11:12 +00:00
林玮 (Jade Lin) 1fa3245e83 fix(app): include image icons in app packages (#42626) 2026-09-21 06:32:21 +00:00
Charles YaoandClaude Opus 5 fe3f8154b5 feat(dify-builder): carry node usage rules on the Builder-only directive
Meeting item 9 asked for per-node usage detail in the generator prompts.
Those prompts are shared with cmd+K, so the detail rides the channel the
Builder already owns instead: _WORKFLOW_TOPOLOGY_DIRECTIVE, prepended to
every generator instruction at build.py:377 and invisible to cmd+K. That
mechanism is proven -- it is why workflow mode never gets 'answer' nodes.

The rules added are the ones that broke real builds: tool nodes expose
text/files/json and not 'result' (ESQ1-296, ESQ1-289), document-extractor
needs a file input, container children belong inside the container
(ESQ1-288), if-else operators must match the variable's type
(ESQ1-285/290), and http-request inputs belong on the start node
(ESQ1-286).

Deliberately does NOT restate the node whitelist: the generator's shared
system prompt already carries it, and duplicating it would burn planner
output budget that ESQ1-300 already implicates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:08:27 -07:00
Charles YaoandClaude Opus 5 f12d8f4e0f feat(dify-builder): plan in Dify node vocabulary, not business language
propose_plan_v1's system prompt was two sentences with no node vocabulary,
so it planned in business language -- "Send a Feishu notification",
"Connect to the database". Those steps became the generator's
"# User instruction" verbatim, asking it to map work onto nodes that do
not exist, and the failure only surfaced at build-generate-graph, minutes
after the user had approved the plan (ESQ1-279).

The generator already knows the node types: its SHARED system prompt
carries the whitelist. The gap was upstream, in the Builder's own planner.
Naming the vocabulary here makes that existing whitelist sufficient.

Builder-only: nothing under core/workflow/generator/ is touched, so the
cmd+K /create and /refine features are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 23:05:34 -07:00
林玮 (Jade Lin) 11038c5936 fix(agent): auto-rename duplicate package imports (#42623) 2026-09-21 05:54:22 +00:00
林玮 (Jade Lin) 99df0ca07f fix(api): mark unavailable legacy DSL agent assets as missing (#42622) 2026-09-21 05:54:14 +00:00
Charles YaoandClaude Opus 5 d2e204f133 fix(dify-builder): stop the test step deadlocking and losing its outcome
Two defects, both introduced by the streaming switch in 3838db8aa7, which
together livelocked every Builder test run.

1. Self-deadlock. run_draft executes inside the Celery task
   dify_builder_advance_task.advance_session. Streaming mode does not run
   the workflow in place: it enqueues workflow_based_app_execution_task
   via delay() on first subscribe (app_generate_service.py:322-324) and
   returns a Redis subscription. The advance task then blocked on events
   from a task needing the worker slot the advance task was holding.
   Observed live: advance_session "succeeded in 300.59s" (the idle
   timeout) four times over, while each workflow_run finished in under 2s.
   Deadlocks every time at worker concurrency 1, and as soon as N test
   runs are in flight at concurrency N.

2. Lost outcome. Blocking mode returns a Mapping
   {"task_id", "workflow_run_id", "data"}, but the rewrite consumed the
   response with `for chunk in response`, which walks a dict's KEYS as
   strings. Probed against the real service: 3 chunks, 0 run-id matches,
   0 terminal matches. So every run was recorded status="running" with an
   empty dify_run_id, and the "running" branch bounced the user back to
   build.execution -- forever. The pre-3838db8aa7 code read
   response["data"] directly; the rewrite dropped that path.

Blocking mode restored, Mapping responses handled, and the shared tail
factored into _finish_run so the two paths cannot disagree about an
outcome. The stream-consumption path is kept and still tested, for a
future consumer that is NOT a worker -- which is what live per-node
streaming to the frontend will require.

Verified end to end on a local stack: one test run, status succeeded with
a real run id, advance_session 0.04-31.8s, and the session reaching
build.review instead of looping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:10:28 -07:00
Charles YaoandClaude Opus 5 674669f7a6 fix(dify-builder): carry the container start marker's ReactFlow type
The previous ESQ1-288 fix dropped the iteration/loop start marker, stripped
start_node_id from the container config and dropped the generator's container
entry edge. All three are required by the ENGINE, not just the canvas:
IterationNodeData.start_node_id / LoopNodeData.start_node_id have no default,
node data is validated eagerly at graph construction, and the body frame is
rooted at start_node_id. Every container workflow the Builder produced was
therefore non-runnable -- worse than the canvas crash it replaced.

The real root cause was never the marker node. The generator already emits it
with the correct node-level ReactFlow type ("custom-iteration-start" /
"custom-loop-start", runner.py:1350-1362); _build_node hardcoded "type":
"custom" and threw it away, leaving a node the frontend has no component for
(React error #130).

Carry it through instead, the same way the previous commit carried
parentId/position: a defaulted `flow_type` on _build_node / apply_create_node
(absent -> "custom", so every existing caller is byte-identical), set from the
source node's type in to_intents. The marker, start_node_id and the entry edge
are all persisted again.

Tests: the three tests that asserted the ABSENCE of the marker, start_node_id
and the entry edge are replaced by ones asserting their presence, plus the
round-trip test that would have caught this -- to_intents -> filter_applicable
-> APPLY_FNS -> IterationNodeData/LoopNodeData.model_validate on the persisted
container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles YaoandClaude Opus 5 f0c7b7c413 feat(dify-builder): carry nesting and layout through translation
to_intents read only id and data, so every node-level ReactFlow key --
parentId, extent, zIndex, position -- was dropped on each Builder build.
Iteration and loop children were written un-nested, not inside their
container at all, and the generator's computed layout was replaced by
default positions. The data-level markers (isInIteration, iteration_id)
survived via config, which is why the graphs looked almost right.

apply_create_node already took position; parent_id is new and implies
extent="parent" + zIndex=1002, matching what the generator computes.

Unfiled; found while tracing ESQ1-288. Skipping the synthetic start node
stops the canvas crash, but an iteration whose children are not inside it
still does not work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles YaoandClaude Opus 5 8ceee4d3be fix(dify-builder): skip synthetic nodes and clean up dangling references
Builder-generated iterations/loops crashed the canvas permanently with
React error #130 or TypeError. The backend synthesizes container-start
markers correctly typed, but to_intents was recreating them as generic
nodes, and persisting dangling start_node_id pointers.

Fix three related issues:
1. Skip creating synthetic iteration-start/loop-start nodes (frontend
   regenerates them at render time from the container).
2. Strip start_node_id from container configs; the frontend's
   workflow-init.ts rewrites it to match the marker it creates, and
   persisting our pointer causes TypeError on dereference without guard.
3. Skip edges whose source or target is a skipped node; the generator
   emits entry edges to the synthetic marker, which no longer exist.

The generator's own output (cmd+K) is untouched — only the Builder
translation layer is fixed. Zero blast radius.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles YaoandClaude Opus 5 9a99804247 fix(dify-builder): restore repair_attempts/last_repair_error on context reload
Critical defect from Task 3 review: context_from_dict rebuilt
DifyBuilderContext field-by-field and never read repair_attempts or
last_repair_error, even though context_to_dict (asdict) wrote them.
BUILD_AWAIT_REPAIR is a waiting state, so every approve_repair is a
fresh turn that reloads the context via context_from_dict on
submit_action -> repair_attempts silently reset to 0 every turn,
incremented to 1, and could never reach _MAX_REPEATED_REPAIRS. The
_repair_is_repeating guard added in Task 3 was therefore structurally
unreachable in production (ESQ1-285/290's dead loop, still alive).

Adds both fields to the context_from_dict constructor call, following
the existing per-field coercion idioms (int() for the counter, matching
checkpoint_seq; bare .get with a "" default for the string, matching
its string-field neighbors).

Also adds:
- A round-trip + defaults-when-absent test pair in test_serde.py,
  following the file's existing convention, reproducing the exact
  reload-drops-fields regression.
- An integration test in test_handlers_build.py that drives the
  "Repeated failure" branch through handle_test_and_repair itself
  (StubAgent + FakeBuildDifyPort, not a direct _repair_is_repeating
  call), so a future regression that re-disconnects serde from the
  handler would be caught even if the pure guard-function unit tests
  still pass in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:26 -07:00
Charles YaoandClaude Opus 5 1b9d301faf fix(dify-builder): reject non-string node ids and paths instead of failing the session
An LLM-supplied int node_id reached diff_graphs, which drops falsy and
non-string ids from its node maps, so result.changes came back empty and
build_change_set's fallback joined a list containing an int. The
TypeError took the whole session to `failed` (ESQ1-271) after the
mutation had already been committed to the draft.

validate_intent_args now rejects a non-string or empty node id, so
filter_applicable turns it into a rejection reason the agent can act on.
build_change_set str()s defensively -- that join must never be what kills
a session.

Also extends the same guard to set_node_config's `path` arg: a non-string
path used to reach _resolve_path's str.split() and raise AttributeError,
surfacing "'int' object has no attribute 'split'" to the repair agent
instead of an actionable rejection reason. Same boundary, same defect
shape, deliberately included here rather than deferred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:25 -07:00
Charles YaoandClaude Opus 5 7e82ab20d9 fix(dify-builder): walk dotted paths in apply_set_node_config
apply_set_node_config wrote node["data"][path] as a flat key, so every
dotted path the repair agent emits (cases.0.conditions.1.value) created a
junk key the workflow engine ignores. The dry run passed, the change set
printed a plausible line, and the graph never changed -- which is why
ESQ1-285 survived nine approvals and ESQ1-290 six.

Paths now resolve through lists and mappings, and an unwalkable path
raises. Raising is half the fix: filter_applicable dry-runs intents
through the real apply_* and feeds rejection reasons back to the repair
agent, so a bad path now produces corrective feedback instead of a
silent no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:09:25 -07:00
林玮 (Jade Lin) e78ce4db64 feat(app): package inline agents in workflow and chatflow exports (#42579) 2026-09-21 02:54:43 +00:00
Wu Tianwei bf8333bc77 fix(builder): avoid layout conflicts and serialize draft updates (#42601) 2026-09-21 09:48:02 +08:00
Charles YaoandClaude Opus 5 a3c2e7fca5 feat(dify-builder): tell the canvas which run to show
Streaming the test run gives the user a live progress bar but no results:
NodeEvent carries node_id/title/status/error and nothing else, so a client
watching the stream learns which node is running, never what it produced.
The results already exist -- the run is a normal InvokeFrom.DEBUGGER draft
run, its node executions are in the database, and the editor's canvas
already knows how to render exactly that view. It was simply never told
which run to open.

So put the Dify run id where a client can reach it:

- NodeEvent gains run_id, filled from the streaming frame. Every non-ping
  frame is stamped with workflow_run_id, so this arrives with the FIRST
  node event -- while the run is still going, which is the point.
- The node SSE frame publishes it alongside the existing fields.
- mark_test_success / mark_test_error carry dify_run_id in both the Build
  and Edit flows, so the terminal state names its run too.

This is what makes per-node results land on the graph, where a node's
inputs and outputs already have a home, instead of only as a JSON blob on
a conversation card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:16:41 -07:00
Charles YaoandClaude Opus 5 69ccf2ad15 fix(dify-builder): wire resource readiness into recommender/gap prompts
Three minor whole-branch-review fixups:

- ResourceOption.readiness ("ready"/"missing_config") was computed but never
  shown to the model: discover_resources's recommender prompt listed ids and
  labels only, so it could recommend an unauthorized tool; _GAP_SYSTEM showed
  readiness in the listing (assess_capability_gap) but never defined what
  missing_config means. Both prompts now show readiness and state that
  missing_config is installed-but-not-authorized, i.e. not usable -- pinned
  with tests asserting the literal "missing_config" in the constructed
  prompts.
- strings.py: dropped three orphaned catalogue entries ("Here's the initial
  plan.", "Ask each time", "Prefer audited") -- the conflict-policy concept
  they backed was removed end to end; nothing in the handlers emits them.
- core/workflow/generator/tool_catalogue.py: provider.need_credentials was
  read OUTSIDE the per-provider try/except that already guards
  get_tools()/tool parsing. This module is shared with the cmd+K /create and
  /refine flows, so a provider whose property raised would break those too,
  not just the Builder. Moved the read inside the try/except so a bad
  provider is skipped like any other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:41:52 -07:00
Charles YaoandClaude Opus 5 f1bf2fb318 feat(dify-builder): say when the workspace cannot cover the plan
Add assess_capability_gap: one plain sentence naming a plan step nothing
installed can perform, or "" when the plan is covered. Grounded only in
installed resources -- there is no marketplace lookup, so it names a missing
CAPABILITY, never a product to install. A missing_config tool (installed but
unauthorized, from the prior task's real readiness) doesn't count as
covering a step either, since the model is shown each option's readiness
alongside its label and kind.

Wired through the full agent seam: the DifyBuilderAgent protocol, the real
LlmBuilderAgent (delegates to build.assess_capability_gap), and the
PlaceholderAgent test double (returns ""). handlers_build.py calls it
after discover_resources and, when non-empty, appends a NoticeItem beside
the existing empty-resources notice -- the two are independent and can
both fire.

Like its sibling cognition functions, it degrades to "" (never raises) on
no model, an LLM error, or an unparseable reply: a gap that can't be
assessed isn't a gap worth inventing. The sentence is LLM-generated prose
already in the user's language (json_language_instruction), not a
catalogue entry, so no static string was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:13:02 -07:00
Charles YaoandClaude Opus 5 0a228b7c23 feat(dify-builder): report whether a recommended tool is actually authorized
The Builder recommended every installed tool as ready to use without
ever checking whether its provider actually has credentials configured.
A credentialed tool with no tool_builtin_providers row got bound into a
plan and then failed at test time with an authorization error the user
can't act on.

tool_catalogue.py now carries provider_controller.need_credentials onto
each ToolCatalogueEntry as needs_credentials. resources.py resolves
which providers are actually authorized for the tenant (mirroring
ToolTransformService.builtin_provider_to_user_provider's need_credentials
/ db_provider rule exactly, so the Builder and the console's tools page
can never disagree) and sets ResourceRef.readiness to "missing_config"
for a credentialed tool with no authorized provider row, "ready"
otherwise. Datasets and models are unaffected. If the authorization
query itself fails, readiness fails open to "ready" rather than
mass-labeling every credentialed tool unauthorized on a transient error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:04:48 -07:00
Charles YaoandClaude Opus 5 574c9d1208 fix(dify-builder): never report a truncated test run as a failed one
Streaming introduced a correctness regression: if the event stream ended
without a terminal frame (idle timeout, dead publishing worker), run_draft
synthesised status="failed" with an empty dify_run_id. A run that had in fact
SUCCEEDED would then be reported as a failed build and sent into the repair
loop. The blocking path would have waited and returned the truth.

The stream is the fast path, not the authority. Every non-ping frame is
stamped with workflow_run_id by convert_stream_full_response, so the run id
is known from the first frame onwards -- long before the terminal frame that
may never arrive. run_draft now captures it from any frame and, when no
terminal frame arrives, reads the run's real status from the workflow-run row
instead of inventing one.

Note that data["id"] is the node-execution id on node frames, not the run id,
so it is only read as a run id on workflow-level frames.

When even the row cannot settle it -- no run id, no row, or a row still
marked running because the worker died mid-run -- the result is reported as
status="running" with an explicit Run.error, which is what Run.status already
documents for "not over / not known". A truncated stream is now either the
truth or a visible unknown, never a fabricated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:49:50 -07:00
Charles YaoandClaude Opus 5 e9599355a2 feat(dify-builder): stream the test run instead of replaying it
run_draft blocked on AppGenerateService.generate(streaming=False) and then
replayed every finished node-execution row through on_event, so the user
watched a spinner for the whole run and then saw every node light up at once.
It now consumes the streaming generator and fires on_event per frame.

Two things the plan assumed that turned out not to hold:

- The stream does NOT yield dicts. convert_stream_full_response yields dicts,
  but that runs in the Celery worker; on our side
  BaseAppGenerator.convert_to_event_stream (not overridden) re-wraps every
  event as "data: {json}\n\n" and every keep-alive as "event: ping\n\n". An
  isinstance(chunk, dict) guard would have dropped every frame. New
  stream_chunk_as_mapping accepts both shapes and rejects keep-alives.

- The streaming workflow_paused frame carries workflow_run_id where the
  blocking response carried id, so map_run_result would KeyError on it.
  Adapted at the call site via run_result_data_from_terminal_chunk;
  map_run_result's contract is unchanged for the fix/edit flows.

Session lifetime: _dispatch_generate is a plain function, so all of its
Session work is eager, and the generator it returns only reads a Redis
subscription while the run executes in a Celery worker with its own session.
The consumption loop therefore sits outside the `with` block rather than
pinning a DB connection for the length of a workflow run; a test pins this.

The node-execution read stays at the end -- map_run_result still needs those
rows for per_node[].outputs. Only the progress streams.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:42:25 -07:00
Charles YaoandClaude Opus 5 a96ed2d387 Builder: fix the continue_adjusting dead end at build.initial_plan
Code review finding: build.initial_plan was left in _WAITING with zero
_ACTIONS_FOR entries but a handler still gated on find_resources. Since a
waiting state settles the runner's advance loop (is_waiting(next_state) ->
settled), continue_adjusting from build.review landed a session there with
no button the FE could render and no action the handler would accept --
the loop-back could never advance.

Reclassify build.initial_plan as working (state.py _WORKING, not _WAITING):
it is now a genuine pass-through step, matching build.test_and_repair /
build.publish / build.governance_feedback. handle_initial_plan drops its
find_resources gate and runs resource discovery unconditionally on entry;
the runner drives it with the action already consumed and falls straight
through to build.resource_recommendation within the same advance() call.
Removed the now-stale BUILD_INITIAL_PLAN entry from service.py's
_BACKEND_ACTIONS_FOR (no action to gate on any more) and the compensating
exemption it required in test_every_backend_waiting_action_has_a_ui_path
(the invariant now holds with no special case).

Added a state-classification test (test_build_initial_plan_is_working_not_
waiting) and an end-to-end regression driving the Runner from build.review
through continue_adjusting with no find_resources action ever sent
(test_continue_adjusting_reaches_resources_without_find_resources_action),
which is the scenario this fix exists to unwedge. Fixed the two existing
idempotency flow tests that asserted the old (broken) two-step loop-back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:50:45 -07:00
Charles YaoandClaude Opus 5 806cc4e517 Builder: collapse the decision-free find_resources gate at build.initial_plan
submit_requirements now tail-calls a shared _discover_and_offer_resources
helper straight into build.resource_recommendation, instead of stopping at
build.initial_plan to show a plan card the user must click through with no
real decision to make. The resource-discovery body (progress reporting,
discover_resources, ResourceSelectCard, empty-options notice, assistant
turn) is extracted verbatim into that helper so the straight-through path
and the continue_adjusting/retry_after_revert loop-back (which still
re-enters at build.initial_plan and still requires find_resources) share
one implementation instead of drifting apart.

build.initial_plan survives as the loop-back target but now offers no UI
action (_ACTIONS_FOR entry removed); its handler still accepts
find_resources at the backend for that loop-back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:37:01 -07:00