From 3daecd505c34d1d6026a29534f64a72704ac7116 Mon Sep 17 00:00:00 2001 From: "J.Jason" <130959319+JJasonSun@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:57:35 +0800 Subject: [PATCH] feat: validate reviewed Goal Run configuration --- README.md | 24 ++ examples/goal-run-configuration.yml | 203 +++++++++++ manifest.yaml | 2 +- provider/call_e.yaml | 1 + pyproject.toml | 2 +- tests/test_goal_configuration.py | 346 +++++++++++++++++++ tests/test_goal_configuration_example.py | 183 ++++++++++ tests/test_plugin_metadata.py | 2 +- tests/test_tools.py | 4 +- tools/prepare_goal_run.py | 90 +++++ tools/prepare_goal_run.yaml | 94 ++++++ utils/goal_configuration.py | 411 +++++++++++++++++++++++ 12 files changed, 1357 insertions(+), 5 deletions(-) create mode 100644 examples/goal-run-configuration.yml create mode 100644 tests/test_goal_configuration.py create mode 100644 tests/test_goal_configuration_example.py create mode 100644 tools/prepare_goal_run.py create mode 100644 tools/prepare_goal_run.yaml create mode 100644 utils/goal_configuration.py diff --git a/README.md b/README.md index d86b7c7..08744e0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This plugin exposes safe outbound phone-call tools for CALL-E: - `get_call` fetches one CALL-E call result by call ID. - `create_and_wait` previews or creates one outbound CALL-E call, then waits for a final result or timeout. - `list_goals` and `get_goal` read published CALL-E Goals without starting a call. +- `prepare_goal_run` selects a published Goal, describes its fields, and validates a reviewed business mapping without starting a call. - `create_goal_run`, `get_goal_run`, and `create_goal_run_and_wait` execute and monitor one phone-specific published Goal. Phone calls are real-world side effects. Live calls require both `dry_run=false` and `confirm_live_call=true`. @@ -106,6 +107,29 @@ The example starts in dry run mode. Supply `phone_number`, `appointment_time`, a Goal Run tools follow the same live-call boundary as Call tools: live execution requires both `dry_run=false` and `confirm_live_call=true`. +#### Configure and review a Goal Run + +Add **Prepare CALL-E Goal Run** before a creation tool. Its **Published Goal** selector lists the authenticated owner's executable Goals by title, published version, and stable Goal ID. It reads the existing Goal APIs, including every catalog page. + +1. Choose the intended Goal and run this read-only node once. The output describes input and result fields, required flags, types, enum options, defaults, and the immutable published RunSpec ID and version. +2. Review those fields, then copy the node's `goal_json` output into its **Reviewed Goal interface** configuration field. Save this value with the workflow. Keep it fixed across business events until you review another publication. +3. Map upstream business data into **Mapped business variables JSON**. Bind a Code node's output instead of assembling a JSON string in a prompt. Preparation merges the published defaults and checks missing, unknown, wrongly typed, and invalid enum values. Boolean `false`, integer `0`, and other business values keep their types. +4. Gate the creation node on `ready=true`. Bind its Goal ID and Variables JSON to the preparation node's `goal_id` and `variables_json` outputs. These routing outputs are empty when preparation fails. The `errors` and `changes` outputs identify fields needing attention. + +Preparation checks the current interface against the saved one, including result mappings. A new publication or changed field requirement stops this path until the operator reviews and updates the saved interface. Unsupported API/schema shapes also stop preparation with a field-level error. Keep the reviewed interface in configuration; binding it to the current `get_goal` response on every run would silently approve changes. + +The API chooses the RunSpec at acceptance. A read-only preflight is not an atomic version selector: publishing between preparation and creation can still change the accepted version. Compare the returned Run's `run_spec` with the prepared ID and version before acting on its result. Retain the accepted Run ID when they differ; do not redial. + +For recovery, retain the original business event, prepared variables, phone, and idempotency key before submission. If creation was uncertain, replay that exact saved request through the existing **Create CALL-E Goal Run** tool, which does not depend on the current Goal interface. If the Run ID is known, resume with **Get CALL-E Goal Run**. A failed configuration check for a new event is not a reason to change a saved recovery key. + +#### Business-field mapping example + +Import [goal-run-configuration.yml](examples/goal-run-configuration.yml) after installing this plugin and configuring its credentials. The example maps a saved record's `reference` to the published Goal's `test_reference` input, and its saved `event_id` to the idempotency key. Use a published integration-test Goal whose required input is a string `test_reference` and whose nullable `confirmed` result is `yes`, `no`, or `unknown`. For another Goal, edit the **Map business fields** and **Check accepted version and result** Code nodes to match the fields shown by preparation. + +Select that Goal in **Review and validate Goal inputs**, run the workflow, review the returned field report, and save its `current_goal_json` in **Reviewed Goal interface**. Supply an owned or explicitly authorized E.164 phone number in `business_record.phone_number`. The placeholder number is blocked for live calls. Keep the record and event ID unchanged for replay. Only after inspecting the preview, change **Execute reviewed Goal** to `dry_run=false` and `confirm_live_call=true` for one authorized test. + +The example gates creation on valid configuration, checks the accepted RunSpec again, and returns `confirmed` only for explicit `result.confirmed=yes` with an available result, completed telephone outcome, no error, and no timeout. Empty, unavailable, unknown, and changed-interface results remain for manual review. Preparation and its field selector never place a call. Mapped variables and Run outputs may contain personal data; restrict access to workflow runs and downstream consumers. + ## Upgrade from 0.1.x Version 0.2.0 moves new Calls to v2. Before upgrading a workflow: diff --git a/examples/goal-run-configuration.yml b/examples/goal-run-configuration.yml new file mode 100644 index 0000000..0051201 --- /dev/null +++ b/examples/goal-run-configuration.yml @@ -0,0 +1,203 @@ +app: + name: CALL-E reviewed Goal Run + description: Map a saved business record, validate a reviewed Goal interface, and run only when ready. Starts in dry run mode. + mode: workflow + icon: telephone_receiver + icon_type: emoji + icon_background: '#E8F0FE' +dependencies: [] +kind: app +version: 0.7.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: {} + graph: + edges: + - {id: start-map, source: start, sourceHandle: source, target: map, targetHandle: target, type: custom, data: {sourceType: start, targetType: code}} + - {id: map-prepare, source: map, sourceHandle: source, target: prepare, targetHandle: target, type: custom, data: {sourceType: code, targetType: tool}} + - {id: prepare-gate, source: prepare, sourceHandle: source, target: gate, targetHandle: target, type: custom, data: {sourceType: tool, targetType: if-else}} + - {id: gate-run, source: gate, sourceHandle: ready, target: run, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: tool}} + - {id: gate-attention, source: gate, sourceHandle: 'false', target: attention, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: end}} + - {id: run-inspect, source: run, sourceHandle: source, target: inspect, targetHandle: target, type: custom, data: {sourceType: tool, targetType: code}} + - {id: inspect-end, source: inspect, sourceHandle: source, target: end, targetHandle: target, type: custom, data: {sourceType: code, targetType: end}} + nodes: + - id: start + type: custom + position: {x: 0, y: 160} + data: + type: start + title: Saved business event + variables: + - variable: business_record + label: Saved business record JSON + type: paragraph + required: true + max_length: 10000 + default: '{"reference":"ORDER-8472","phone_number":"+14155550123","event_id":"order-8472:confirm:1"}' + - id: map + type: custom + position: {x: 300, y: 160} + data: + type: code + title: Map business fields + code_language: python3 + variables: + - {variable: business_record, value_selector: [start, business_record]} + outputs: + variables_json: {type: string, children: null} + phone_number: {type: string, children: null} + idempotency_key: {type: string, children: null} + code: | + import json + + def main(business_record: str) -> dict: + record = json.loads(business_record) + if not isinstance(record, dict): + raise ValueError("Business record must be a JSON object.") + for field in ("reference", "phone_number", "event_id"): + if not isinstance(record.get(field), str) or not record[field].strip(): + raise ValueError("Business record requires a non-empty " + field + ".") + return { + "variables_json": json.dumps({"test_reference": record["reference"]}, sort_keys=True), + "phone_number": record["phone_number"], + "idempotency_key": record["event_id"], + } + - id: prepare + type: custom + position: {x: 600, y: 160} + data: + type: tool + title: Review and validate Goal inputs + provider_type: builtin + provider_id: derekwang2002/call_e/call_e + provider_name: derekwang2002/call_e/call_e + provider_show_name: CALL-E + plugin_id: derekwang2002/call_e + tool_name: prepare_goal_run + tool_label: Prepare CALL-E Goal Run + tool_node_version: '2' + tool_configurations: + goal_id: {type: constant, value: ''} + reviewed_goal_json: {type: constant, value: ''} + tool_parameters: + variables_json: {type: variable, value: [map, variables_json]} + - id: gate + type: custom + position: {x: 900, y: 160} + data: + type: if-else + title: Configuration ready? + cases: + - case_id: ready + logical_operator: and + conditions: + - {id: configuration-ready, variable_selector: [prepare, ready], varType: boolean, comparison_operator: is, value: true} + - id: run + type: custom + position: {x: 1200, y: 0} + data: + type: tool + title: Execute reviewed Goal + provider_type: builtin + provider_id: derekwang2002/call_e/call_e + provider_name: derekwang2002/call_e/call_e + provider_show_name: CALL-E + plugin_id: derekwang2002/call_e + tool_name: create_goal_run_and_wait + tool_label: Create And Wait For CALL-E Goal Run + tool_node_version: '2' + tool_configurations: {} + tool_parameters: + goal_id: {type: variable, value: [prepare, goal_id]} + variables_json: {type: variable, value: [prepare, variables_json]} + phone_number: {type: variable, value: [map, phone_number]} + idempotency_key: {type: variable, value: [map, idempotency_key]} + dry_run: {type: constant, value: true} + confirm_live_call: {type: constant, value: false} + poll_interval_seconds: {type: constant, value: 5} + wait_timeout_minutes: {type: constant, value: 4} + - id: inspect + type: custom + position: {x: 1500, y: 0} + data: + type: code + title: Check accepted version and result + code_language: python3 + variables: + - {variable: tool_output, value_selector: [run, json]} + - {variable: expected_id, value_selector: [prepare, run_spec_id]} + - {variable: expected_version, value_selector: [prepare, run_spec_version]} + outputs: + route: {type: string, children: null} + goal_run_id: {type: string, children: null} + business_result_json: {type: string, children: null} + error_json: {type: string, children: null} + code: | + import json + + def main(tool_output: list, expected_id: str, expected_version: int) -> dict: + envelope = tool_output[0] if tool_output and isinstance(tool_output[0], dict) else {} + resource = envelope.get("goal_run") + if not isinstance(resource, dict): + resource = envelope.get("accepted_resource") + resource = resource if isinstance(resource, dict) else {} + goal_run_id = envelope.get("goal_run_id") or resource.get("id") or "" + error = envelope.get("error") + if error is None: + error = resource.get("error") + result = resource.get("result") + route = "manual_review" + if not goal_run_id and envelope.get("dry_run") is True and error is None: + route = "preview" + elif goal_run_id: + spec = resource.get("run_spec") + same_version = ( + isinstance(spec, dict) + and spec.get("id") == expected_id + and type(spec.get("version")) is int + and spec["version"] == expected_version + ) + if not same_version: + route = "interface_changed_after_acceptance" + elif ( + resource.get("object") == "goal_run" + and resource.get("result_status") == "available" + and resource.get("call_outcome") == "completed" + and error is None + and not envelope.get("timed_out") + and isinstance(result, dict) + and result.get("confirmed") == "yes" + ): + route = "confirmed" + return { + "route": route, + "goal_run_id": goal_run_id, + "business_result_json": json.dumps(result, ensure_ascii=False), + "error_json": json.dumps(error, ensure_ascii=False), + } + - id: attention + type: custom + position: {x: 1200, y: 320} + data: + type: end + title: Review configuration + outputs: + - {variable: ready, value_selector: [prepare, ready], value_type: boolean} + - {variable: configuration_report, value_selector: [prepare, report], value_type: string} + - {variable: validation_errors, value_selector: [prepare, errors], value_type: 'array[object]'} + - {variable: interface_changes, value_selector: [prepare, changes], value_type: 'array[object]'} + - {variable: current_goal_json, value_selector: [prepare, goal_json], value_type: string} + - id: end + type: custom + position: {x: 1800, y: 0} + data: + type: end + title: Goal Run outcome + outputs: + - {variable: route, value_selector: [inspect, route], value_type: string} + - {variable: goal_run_id, value_selector: [inspect, goal_run_id], value_type: string} + - {variable: business_result_json, value_selector: [inspect, business_result_json], value_type: string} + - {variable: error_json, value_selector: [inspect, error_json], value_type: string} + - {variable: idempotency_key, value_selector: [map, idempotency_key], value_type: string} + - {variable: response, value_selector: [run, json], value_type: 'array[object]'} diff --git a/manifest.yaml b/manifest.yaml index 6eeed0a..a4337d6 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -1,4 +1,4 @@ -version: 0.3.0 +version: 0.4.0 type: plugin author: derekwang2002 name: call_e diff --git a/provider/call_e.yaml b/provider/call_e.yaml index fffbe8e..19c6940 100644 --- a/provider/call_e.yaml +++ b/provider/call_e.yaml @@ -38,6 +38,7 @@ tools: - tools/create_and_wait.yaml - tools/list_goals.yaml - tools/get_goal.yaml + - tools/prepare_goal_run.yaml - tools/create_goal_run.yaml - tools/get_goal_run.yaml - tools/create_goal_run_and_wait.yaml diff --git a/pyproject.toml b/pyproject.toml index 0deb473..6d710c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "call-e-dify-plugin" -version = "0.3.0" +version = "0.4.0" description = "CALL-E tools for Dify workflows and agents" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_goal_configuration.py b/tests/test_goal_configuration.py new file mode 100644 index 0000000..8958722 --- /dev/null +++ b/tests/test_goal_configuration.py @@ -0,0 +1,346 @@ +import copy +import json + +import pytest + +from tools.create_goal_run import CreateGoalRunTool +from tools.create_goal_run_and_wait import CreateGoalRunAndWaitTool +from tools.prepare_goal_run import PrepareGoalRunTool +from utils.client import CalleApiError, CalleClient +from utils.goal_configuration import ( + GoalConfigurationError, + goal_interface, + prepare_configuration, +) + + +@pytest.fixture +def goal(): + return { + "object": "goal", + "id": "goal_example", + "title": "Reference confirmation", + "description": "Confirm one supplied business reference.", + "status": "active", + "published_run_spec": { + "id": "rspec_example_v1", + "version": 1, + "input_schema": { + "type": "object", + "additionalProperties": False, + "required": ["reference"], + "properties": { + "reference": { + "type": "string", + "description": "Map the upstream record reference.", + }, + "notify": {"type": "boolean", "default": False}, + "attempt": {"type": "integer", "default": 0}, + "score": {"type": "number", "default": 1, "enum": [1, 2]}, + }, + }, + "result_schema": { + "type": "object", + "additionalProperties": False, + "required": ["decision"], + "properties": { + "decision": { + "type": ["null", "string"], + "enum": [None, "confirmed", "unknown"], + } + }, + }, + }, + } + + +def invoke(tool_class, parameters): + tool = tool_class.from_credentials({"api_key": "test_key"}) + return [message.model_dump(mode="json") for message in tool.invoke(parameters)] + + +def output(messages): + return next( + message["message"]["json_object"] + for message in messages + if message["type"] == "json" + ) + + +def test_preparation_preserves_values_merges_defaults_and_does_not_mutate_inputs(goal): + original = copy.deepcopy(goal) + variables = { + "reference": "+14155550100", + "notify": False, + "attempt": 0, + "score": 1.0, + } + result = prepare_configuration(goal, variables, json.dumps(goal)) + assert result["ready"] and result["goal_id"] == goal["id"] + assert json.loads(result["variables_json"]) == variables + assert type(json.loads(result["variables_json"])["notify"]) is bool + assert type(json.loads(result["variables_json"])["attempt"]) is int + assert goal == original and variables["attempt"] == 0 + assert variables["reference"] not in result["report"] + fields = {field["name"]: field for field in result["input_fields"]} + assert fields["reference"]["required"] + assert fields["notify"]["default_json"] == "false" + assert fields["attempt"]["default_json"] == "0" + assert result["result_fields"][0]["type"] == "null | string" + assert ( + result["run_spec_id"] == "rspec_example_v1" and result["run_spec_version"] == 1 + ) + + +def test_published_defaults_are_applied_before_required_checks(goal): + schema = goal["published_run_spec"]["input_schema"] + schema["properties"]["reference"]["default"] = "DEFAULT-REFERENCE" + result = prepare_configuration(goal, "{}", goal) + assert result["ready"] + assert json.loads(result["variables_json"]) == { + "reference": "DEFAULT-REFERENCE", + "notify": False, + "attempt": 0, + "score": 1, + } + + +def test_first_read_explains_fields_without_authorizing_creation(goal): + result = prepare_configuration(goal, {"reference": "REF-1"}, "") + assert result["ready"] is False + assert result["goal_id"] == result["variables_json"] == "" + assert result["errors"][0]["code"] == "review_required" + assert json.loads(result["goal_json"]) == goal + assert result["input_fields"] and "Review" in result["report"] + + +@pytest.mark.parametrize( + "variables,code,path", + [ + ({}, "missing_required", "variables.reference"), + ( + {"reference": "REF", "unexpected": "private value"}, + "unknown_field", + "variables.unexpected", + ), + ({"reference": 1}, "type_mismatch", "variables.reference"), + ({"reference": None}, "type_mismatch", "variables.reference"), + ({"reference": ["REF"]}, "type_mismatch", "variables.reference"), + ({"reference": {"nested": "REF"}}, "type_mismatch", "variables.reference"), + ({"reference": "REF", "notify": 0}, "type_mismatch", "variables.notify"), + ({"reference": "REF", "attempt": False}, "type_mismatch", "variables.attempt"), + ({"reference": "REF", "attempt": 1.0}, "type_mismatch", "variables.attempt"), + ({"reference": "REF", "score": True}, "type_mismatch", "variables.score"), + ({"reference": "REF", "score": 3}, "enum_mismatch", "variables.score"), + ( + {"reference": "REF", "score": float("inf")}, + "type_mismatch", + "variables.score", + ), + ('{"reference":"A","reference":"B"}', "invalid_json", "variables_json"), + ('{"reference":"REF","score":NaN}', "invalid_json", "variables_json"), + ('{"reference":"REF","score":1e999}', "type_mismatch", "variables.score"), + ("[]", "invalid_object", "variables_json"), + ("not JSON", "invalid_json", "variables_json"), + ], +) +def test_invalid_business_mappings_are_field_errors_without_routing_outputs( + goal, variables, code, path +): + result = prepare_configuration(goal, variables, goal) + assert result["ready"] is False + assert result["goal_id"] == result["variables_json"] == "" + assert any( + item["code"] == code and item["path"] == path for item in result["errors"] + ) + assert "private value" not in json.dumps(result["errors"]) + + +@pytest.mark.parametrize( + "change,affected", + [ + ("publication", "published_run_spec"), + ("input_type", "input_schema.reference"), + ("required_added", "input_schema.new_field"), + ("default", "input_schema.attempt"), + ("result", "result_schema.decision"), + ("different_goal", "goal.id"), + ], +) +def test_changed_interfaces_name_affected_fields_and_need_explicit_review( + goal, change, affected +): + reviewed = copy.deepcopy(goal) + spec = goal["published_run_spec"] + if change == "publication": + spec.update(id="rspec_example_v2", version=2) + elif change == "input_type": + spec["input_schema"]["properties"]["reference"]["type"] = "number" + elif change == "required_added": + spec["input_schema"]["properties"]["new_field"] = {"type": "string"} + spec["input_schema"]["required"].append("new_field") + elif change == "default": + spec["input_schema"]["properties"]["attempt"]["default"] = 1 + elif change == "result": + spec["result_schema"]["properties"]["decision"]["enum"].append("declined") + else: + goal["id"] = "goal_other" + result = prepare_configuration(goal, {"reference": "REF"}, reviewed) + assert not result["ready"] and result["goal_id"] == "" + assert any(item["path"] == affected for item in result["changes"]) + assert any(item["code"] == "goal_interface_changed" for item in result["errors"]) + assert json.loads(result["goal_json"]) == goal + assert reviewed != goal + + +@pytest.mark.parametrize("reviewed", ["[]", "{}", "not JSON", '{"id":"A","id":"B"}']) +def test_invalid_reviewed_interface_is_actionable(goal, reviewed): + result = prepare_configuration(goal, {"reference": "REF"}, reviewed) + assert not result["ready"] + assert any( + item["path"].startswith("reviewed_goal_json.") for item in result["errors"] + ) + + +@pytest.mark.parametrize( + "mutate", + [ + lambda g: g.update(object="future_goal"), + lambda g: g.update(status="paused"), + lambda g: g.pop("published_run_spec"), + lambda g: g["published_run_spec"].update(version=True), + lambda g: g["published_run_spec"]["input_schema"].update( + additionalProperties=True + ), + lambda g: g["published_run_spec"]["input_schema"].update( + format="future-profile" + ), + lambda g: g["published_run_spec"]["input_schema"]["properties"][ + "reference" + ].update(type="array"), + lambda g: g["published_run_spec"]["input_schema"]["properties"][ + "reference" + ].update(pattern=".+"), + lambda g: g["published_run_spec"]["input_schema"]["properties"][ + "notify" + ].update(default=0), + lambda g: g["published_run_spec"]["input_schema"]["properties"][ + "reference" + ].update(description="x" * 513), + lambda g: g["published_run_spec"]["result_schema"]["properties"][ + "decision" + ].update(type="object"), + ], +) +def test_unsupported_api_interfaces_fail_closed_with_an_explicit_hint(goal, mutate): + mutate(goal) + with pytest.raises(GoalConfigurationError) as error: + goal_interface(goal) + assert error.value.finding["code"] == "unsupported_goal_interface" + assert error.value.finding["message"] + + +def test_selected_goal_identity_must_match_the_response(goal): + with pytest.raises(GoalConfigurationError, match="does not match"): + goal_interface(goal, "goal_other") + + +def test_prepare_tool_emits_named_outputs_and_only_reads_the_goal(goal, monkeypatch): + reads = [] + monkeypatch.setattr( + CalleClient, "get_goal", lambda self, goal_id: reads.append(goal_id) or goal + ) + monkeypatch.setattr( + CalleClient, + "create_goal_run", + lambda *a, **k: pytest.fail("Preparation must not create a Run"), + ) + messages = invoke( + PrepareGoalRunTool, + { + "goal_id": goal["id"], + "variables_json": '{"reference":"REF-1"}', + "reviewed_goal_json": json.dumps(goal), + }, + ) + result = output(messages) + named = { + message["message"]["variable_name"]: message["message"]["variable_value"] + for message in messages + if message["type"] == "variable" + } + assert named == result and result["ready"] + assert reads == [goal["id"]] + + +@pytest.mark.parametrize("creator", [CreateGoalRunTool, CreateGoalRunAndWaitTool]) +def test_invalid_configuration_cannot_reach_an_ungated_creation_tool( + goal, monkeypatch, creator +): + result = prepare_configuration(goal, {}, goal) + monkeypatch.setattr( + CalleClient, + "create_goal_run", + lambda *a, **k: pytest.fail("Invalid mapping must not create a Run"), + ) + messages = invoke( + creator, + { + "goal_id": result["goal_id"], + "variables_json": result["variables_json"], + "phone_number": "+14155550100", + "idempotency_key": "event-8472", + "dry_run": False, + "confirm_live_call": True, + }, + ) + assert any( + "goal_id" in item.get("message", {}).get("text", "") for item in messages + ) + + +def test_prepare_lookup_error_keeps_ready_false(goal, monkeypatch): + def fail(*args): + raise CalleApiError( + "The selected Goal is not accessible.", error_code="not_found" + ) + + monkeypatch.setattr(CalleClient, "get_goal", fail) + result = output(invoke(PrepareGoalRunTool, {"goal_id": goal["id"]})) + assert not result["ready"] and result["goal_id"] == "" + assert result["errors"][0]["code"] == "not_found" + + +def test_goal_selector_uses_titles_ids_versions_and_every_page(goal, monkeypatch): + other = copy.deepcopy(goal) + other.update(id="goal_other", title=None) + reads = [] + + def read(self, *, after, limit): + reads.append((after, limit)) + return ( + {"data": [goal], "next_cursor": "opaque-page"} + if after is None + else {"data": [other], "next_cursor": None} + ) + + monkeypatch.setattr(CalleClient, "list_goals", read) + tool = PrepareGoalRunTool.from_credentials({"api_key": "test_key"}) + options = tool.fetch_parameter_options("goal_id") + assert [item.value for item in options] == ["goal_example", "goal_other"] + assert ( + "Reference confirmation" in options[0].label.model_dump(by_alias=True)["en_US"] + ) + assert "v1" in options[0].label.model_dump(by_alias=True)["en_US"] + assert reads == [(None, 100), ("opaque-page", 100)] + + +def test_goal_selector_rejects_a_repeated_cursor(goal, monkeypatch): + monkeypatch.setattr( + CalleClient, + "list_goals", + lambda *a, **k: {"data": [goal], "next_cursor": "same"}, + ) + tool = PrepareGoalRunTool.from_credentials({"api_key": "test_key"}) + with pytest.raises(ValueError, match="non-progressing"): + tool.fetch_parameter_options("goal_id") diff --git a/tests/test_goal_configuration_example.py b/tests/test_goal_configuration_example.py new file mode 100644 index 0000000..44417b9 --- /dev/null +++ b/tests/test_goal_configuration_example.py @@ -0,0 +1,183 @@ +import copy +import json +from pathlib import Path + +import pytest +import yaml + +from tools.create_goal_run_and_wait import CreateGoalRunAndWaitTool +from utils.client import CalleClient + + +EXAMPLE = yaml.safe_load( + ( + Path(__file__).resolve().parents[1] / "examples/goal-run-configuration.yml" + ).read_text() +) +NODES = {node["id"]: node["data"] for node in EXAMPLE["workflow"]["graph"]["nodes"]} + + +def run_code(name, *args): + namespace = {} + exec(NODES[name]["code"], namespace) + return namespace["main"](*args) + + +def test_mapping_uses_saved_business_identity_and_declared_goal_fields(): + record = { + "reference": "ORDER-8472", + "phone_number": "+14155550100", + "event_id": "order-8472:confirm:1", + } + first = run_code("map", json.dumps(record)) + assert first == run_code("map", json.dumps(record)) + assert json.loads(first["variables_json"]) == { + "test_reference": record["reference"] + } + assert first["idempotency_key"] == record["event_id"] + assert first["phone_number"] == record["phone_number"] + + +@pytest.mark.parametrize("field", ["reference", "phone_number", "event_id"]) +def test_mapping_rejects_missing_business_fields(field): + record = { + "reference": "ORDER-8472", + "phone_number": "+14155550100", + "event_id": "event-8472", + } + del record[field] + with pytest.raises(ValueError, match=field): + run_code("map", json.dumps(record)) + + +def test_graph_gates_creation_and_defaults_to_preview(): + edges = EXAMPLE["workflow"]["graph"]["edges"] + assert ( + next(edge for edge in edges if edge["target"] == "run")["sourceHandle"] + == "ready" + ) + assert NODES["gate"]["cases"][0]["conditions"][0]["variable_selector"] == [ + "prepare", + "ready", + ] + parameters = NODES["run"]["tool_parameters"] + assert parameters["goal_id"]["value"] == ["prepare", "goal_id"] + assert parameters["variables_json"]["value"] == ["prepare", "variables_json"] + assert parameters["dry_run"]["value"] is True + assert parameters["confirm_live_call"]["value"] is False + assert ( + NODES["prepare"]["tool_configurations"]["reviewed_goal_json"]["type"] + == "constant" + ) + + +def accepted(): + return { + "goal_run_id": "rgrp_synthetic_8472", + "timed_out": False, + "result": {"confirmed": "yes"}, + "goal_run": { + "object": "goal_run", + "id": "rgrp_synthetic_8472", + "run_spec": {"id": "rspec_example_v1", "version": 1}, + "result_status": "available", + "call_outcome": "completed", + "result": {"confirmed": "yes"}, + "error": None, + }, + } + + +def test_explicit_available_confirmation_and_matching_interface_select_confirmation(): + result = run_code("inspect", [accepted()], "rspec_example_v1", 1) + assert ( + result["route"] == "confirmed" + and result["goal_run_id"] == "rgrp_synthetic_8472" + ) + + +def test_inspection_consumes_the_execution_tools_actual_output(monkeypatch): + resource = accepted()["goal_run"] + monkeypatch.setattr( + CalleClient, + "create_goal_run_and_wait", + lambda *args, **kwargs: (resource, 1, False), + ) + tool = CreateGoalRunAndWaitTool.from_credentials({"api_key": "test_key"}) + messages = list( + tool.invoke( + { + "goal_id": "goal_example", + "phone_number": "+14155550100", + "variables_json": '{"test_reference":"event-8472"}', + "idempotency_key": "event-8472", + "dry_run": False, + "confirm_live_call": True, + } + ) + ) + output = [ + message.message.json_object + for message in messages + if message.type.value == "json" + ] + result = run_code("inspect", output, "rspec_example_v1", 1) + assert result["route"] == "confirmed" + assert json.loads(result["business_result_json"]) == {"confirmed": "yes"} + + +@pytest.mark.parametrize( + "mutate", + [ + lambda r: r["goal_run"].update(result=None), + lambda r: r["goal_run"].update(result={}), + lambda r: r["goal_run"].update(result={"confirmed": None}), + lambda r: r["goal_run"].update(result={"confirmed": "unknown"}), + lambda r: r["goal_run"].update(result={"confirmed": True}), + lambda r: r["goal_run"].update(result_status="unavailable"), + lambda r: r["goal_run"].update(result_status="pending"), + lambda r: r["goal_run"].update(call_outcome="failed"), + lambda r: r["goal_run"].update(error={}), + lambda r: r.update(error={"code": "lookup_failed"}), + lambda r: r.update(timed_out=True), + ], +) +def test_uncertain_results_do_not_confirm_business_success(mutate): + response = accepted() + mutate(response) + assert ( + run_code("inspect", [response], "rspec_example_v1", 1)["route"] + == "manual_review" + ) + + +def test_publication_between_preparation_and_acceptance_preserves_the_accepted_id(): + response = accepted() + response["goal_run"]["run_spec"] = {"id": "rspec_example_v2", "version": 2} + result = run_code("inspect", [response], "rspec_example_v1", 1) + assert result["route"] == "interface_changed_after_acceptance" + assert result["goal_run_id"] == "rgrp_synthetic_8472" + assert not any( + edge["target"] == "run" and edge["source"] in {"inspect", "end"} + for edge in EXAMPLE["workflow"]["graph"]["edges"] + ) + + +def test_poll_failure_retains_the_accepted_run_for_recovery(): + response = accepted() + raw = response.pop("goal_run") + response.update(accepted_resource=raw, error={"code": "lookup_failed"}) + result = run_code("inspect", [response], "rspec_example_v1", 1) + assert result["route"] == "manual_review" and result["goal_run_id"] == raw["id"] + + +def test_preview_is_not_a_confirmed_goal_execution(): + result = run_code("inspect", [{"dry_run": True}], "rspec_example_v1", 1) + assert result["route"] == "preview" and result["goal_run_id"] == "" + + +def test_inspection_does_not_mutate_tool_results(): + response = accepted() + before = copy.deepcopy(response) + run_code("inspect", [response], "rspec_example_v1", 1) + assert response == before diff --git a/tests/test_plugin_metadata.py b/tests/test_plugin_metadata.py index 7aac1ce..f049924 100644 --- a/tests/test_plugin_metadata.py +++ b/tests/test_plugin_metadata.py @@ -64,7 +64,7 @@ def test_release_version_is_synchronized(): with (ROOT / "pyproject.toml").open("rb") as pyproject_file: pyproject = tomllib.load(pyproject_file) - assert manifest["version"] == "0.3.0" + assert manifest["version"] == "0.4.0" assert pyproject["project"]["version"] == manifest["version"] diff --git a/tests/test_tools.py b/tests/test_tools.py index 480cbc3..7f2706d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -31,9 +31,9 @@ def json_output(messages): return next(message["message"]["json_object"] for message in messages if message["type"] == "json") -def test_dify_registers_all_eight_tools(): +def test_dify_registers_all_nine_tools(): registration = PluginRegistration(DifyPluginEnv()) - assert len(registration.tools_mapping["call_e"][2]) == 8 + assert len(registration.tools_mapping["call_e"][2]) == 9 @pytest.mark.parametrize("tool_class", CREATE_TOOLS) diff --git a/tools/prepare_goal_run.py b/tools/prepare_goal_run.py new file mode 100644 index 0000000..78a7d0c --- /dev/null +++ b/tools/prepare_goal_run.py @@ -0,0 +1,90 @@ +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities import I18nObject, ParameterOption +from dify_plugin.entities.tool import ToolInvokeMessage + +from utils.client import CalleApiError, CalleClient, normalize_base_url +from utils.goal_configuration import ( + GoalConfigurationError, + goal_interface, + prepare_configuration, +) +from utils.messages import result_messages + + +class PrepareGoalRunTool(Tool): + def _client(self) -> CalleClient: + credentials = self.runtime.credentials + return CalleClient( + api_key=credentials.get("api_key"), + base_url=normalize_base_url(credentials.get("base_url")), + ) + + def _fetch_parameter_options(self, parameter: str) -> list[ParameterOption]: + if parameter != "goal_id": + raise ValueError("Only goal_id supports dynamic options.") + client = self._client() + options = [] + after = None + seen = set() + while True: + page = client.list_goals(after=after, limit=100) + if not isinstance(page.get("data"), list): + raise ValueError("The Goal catalog must return a data array.") + for item in page["data"]: + goal = goal_interface(item) + label = f"{goal.get('title') or goal['id']} — v{goal['published_run_spec']['version']} ({goal['id']})" + options.append( + ParameterOption(value=goal["id"], label=I18nObject(en_US=label)) + ) + after = page.get("next_cursor") + if after is None: + return options + if not isinstance(after, str) or not after or after in seen: + raise ValueError("The Goal catalog returned a non-progressing cursor.") + seen.add(after) + + def _invoke( + self, tool_parameters: dict[str, Any] + ) -> Generator[ToolInvokeMessage, None, None]: + try: + goal_id = str(tool_parameters.get("goal_id") or "").strip() + if not goal_id: + raise GoalConfigurationError( + "goal_required", + "goal_id", + "Choose one published Goal from the selector.", + ) + goal = goal_interface(self._client().get_goal(goal_id), goal_id) + result = prepare_configuration( + goal, + tool_parameters.get("variables_json"), + tool_parameters.get("reviewed_goal_json"), + ) + except (GoalConfigurationError, CalleApiError) as error: + finding = ( + error.finding + if isinstance(error, GoalConfigurationError) + else { + "code": error.error_code or "goal_lookup_failed", + "path": "goal_id", + "message": str(error), + } + ) + result = { + "ready": False, + "goal_id": "", + "run_spec_id": "", + "run_spec_version": 0, + "goal_json": "", + "input_fields": [], + "result_fields": [], + "variables_json": "", + "errors": [finding], + "changes": [], + "report": finding["message"], + } + yield self.create_text_message(result["report"]) + yield from result_messages(self, result) diff --git a/tools/prepare_goal_run.yaml b/tools/prepare_goal_run.yaml new file mode 100644 index 0000000..d32f611 --- /dev/null +++ b/tools/prepare_goal_run.yaml @@ -0,0 +1,94 @@ +identity: + name: prepare_goal_run + author: derekwang2002 + label: + en_US: Prepare CALL-E Goal Run +description: + human: + en_US: Choose a published Goal, review its fields and version, and validate mapped variables without creating a call. + llm: Read-only Goal configuration check. Use ready to gate creation, then bind goal_id and variables_json to the existing Goal Run tool. Keep the reviewed interface fixed until an operator reviews a new publication. +parameters: + - name: goal_id + type: dynamic-select + required: true + form: form + label: + en_US: Published Goal + human_description: + en_US: Choose the intended Goal by title, current version, and ID. Refresh the selector after publishing a Goal. + - name: reviewed_goal_json + type: string + required: false + form: form + label: + en_US: Reviewed Goal interface + human_description: + en_US: Run this read-only node once to inspect the fields, then copy its goal_json output here. Keep it saved in workflow configuration; do not replace it automatically on each run. + - name: variables_json + type: string + required: false + form: llm + label: + en_US: Mapped business variables JSON + human_description: + en_US: Bind a Code node's JSON object string containing business fields declared by the selected Goal. Defaults come from the reviewed published input schema. +extra: + python: + source: tools/prepare_goal_run.py +output_schema: + type: object + properties: + ready: + type: boolean + goal_id: + type: string + run_spec_id: + type: string + run_spec_version: + type: number + goal_json: + type: string + variables_json: + type: string + report: + type: string + input_fields: + type: array + items: &field + type: object + properties: + name: + type: string + type: + type: string + required: + type: boolean + description: + type: string + default_json: + type: string + enum_json: + type: string + result_fields: + type: array + items: *field + errors: + type: array + items: + type: object + properties: + code: + type: string + path: + type: string + message: + type: string + changes: + type: array + items: + type: object + properties: + path: + type: string + message: + type: string diff --git a/utils/goal_configuration.py b/utils/goal_configuration.py new file mode 100644 index 0000000..57d0417 --- /dev/null +++ b/utils/goal_configuration.py @@ -0,0 +1,411 @@ +"""Read and validate the published Goal interface used by a workflow mapping.""" + +import json +import math +import re +from typing import Any + + +SCALAR_TYPES = {"string", "integer", "number", "boolean"} +ROOT_KEYS = { + "$schema", + "type", + "description", + "properties", + "required", + "additionalProperties", +} +PROPERTY_KEYS = {"type", "description", "enum"} +INPUT_PROPERTY_KEYS = PROPERTY_KEYS | {"default", "x-calle-source"} + + +class GoalConfigurationError(ValueError): + def __init__(self, code: str, path: str, message: str): + self.finding = {"code": code, "path": path, "message": message} + super().__init__(message) + + +def _object(value: Any, path: str) -> dict[str, Any]: + def pairs(items): + result = {} + for name, item in items: + if name in result: + raise ValueError("Duplicate JSON keys are not supported.") + result[name] = item + return result + + def constant(_value): + raise ValueError("Non-finite JSON numbers are not supported.") + + if value is None or value == "": + return {} + if isinstance(value, str): + try: + value = json.loads(value, object_pairs_hook=pairs, parse_constant=constant) + except ValueError as error: + raise GoalConfigurationError( + "invalid_json", + path, + "Provide one JSON object with distinct keys and finite numbers.", + ) from error + if not isinstance(value, dict): + raise GoalConfigurationError("invalid_object", path, "Provide one JSON object.") + return value + + +def _matches(value: Any, kind: str | list[str]) -> bool: + if isinstance(kind, list): + return any( + value is None if item == "null" else _matches(value, item) for item in kind + ) + if kind == "string": + return isinstance(value, str) + if kind == "boolean": + return isinstance(value, bool) + if kind == "integer": + return type(value) is int + if kind == "number": + return type(value) in (int, float) and ( + type(value) is int or math.isfinite(value) + ) + return False + + +def _same_value(left: Any, right: Any) -> bool: + if type(left) in (int, float) and type(right) in (int, float): + return left == right + return type(left) is type(right) and left == right + + +def _schema(value: Any, path: str, *, result: bool = False) -> dict[str, Any]: + def reject(message): + raise GoalConfigurationError("unsupported_goal_interface", path, message) + + if not isinstance(value, dict): + reject("The published interface must contain an object schema.") + unknown = sorted(set(value) - ROOT_KEYS) + if unknown: + reject( + "Unsupported schema keywords: " + + ", ".join(unknown) + + ". Review this interface before updating the plugin." + ) + if value.get("$schema") not in ( + None, + "https://json-schema.org/draft/2020-12/schema", + ): + reject("The published interface uses an unsupported JSON Schema dialect.") + if value.get("type") != "object" or value.get("additionalProperties") is not False: + reject("The published schema must be a closed object.") + if "description" in value and ( + not isinstance(value["description"], str) or len(value["description"]) > 512 + ): + reject("The schema description must contain at most 512 characters.") + properties = value.get("properties") + if not isinstance(properties, dict) or len(properties) > 32: + reject("The published schema must declare at most 32 scalar fields.") + required = value.get("required", []) + if not isinstance(required, list) or any( + not isinstance(name, str) for name in required + ): + reject("Schema required must list declared field names.") + if len(set(required)) != len(required) or set(required) - set(properties): + reject("Schema required must contain distinct declared field names.") + for name, prop in properties.items(): + if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", name): + reject("The published schema has an unsupported field name.") + if not isinstance(prop, dict) or set(prop) - ( + PROPERTY_KEYS if result else INPUT_PROPERTY_KEYS + ): + reject( + f"{name}: unsupported field schema keywords. Review this interface before updating the plugin." + ) + kind = prop.get("type") + nullable = ( + result and isinstance(kind, list) and len(kind) == 2 and "null" in kind + ) + if nullable: + valid_type = ( + all(isinstance(item, str) for item in kind) + and len(set(kind)) == 2 + and set(kind) - {"null"} <= SCALAR_TYPES + ) + else: + valid_type = isinstance(kind, str) and kind in SCALAR_TYPES + if not valid_type: + reject( + f"{name}: only scalar input fields and nullable scalar result fields are supported." + ) + if "description" in prop and ( + not isinstance(prop["description"], str) or len(prop["description"]) > 512 + ): + reject(f"{name}: the description must contain at most 512 characters.") + if "default" in prop and (result or not _matches(prop["default"], kind)): + reject(f"{name}: the default does not match its input type.") + if not result and name not in required and "default" not in prop: + reject(f"{name}: an optional input must declare a default.") + if "x-calle-source" in prop and ( + not isinstance(prop["x-calle-source"], str) + or not prop["x-calle-source"].strip() + or "default" in prop + ): + reject(f"{name}: invalid source binding in the published interface.") + if "enum" in prop: + values = prop["enum"] + if ( + not isinstance(values, list) + or not 1 <= len(values) <= 32 + or any(not _matches(item, kind) for item in values) + ): + reject(f"{name}: enum values must match the published scalar type.") + if any( + _same_value(item, previous) + for index, item in enumerate(values) + for previous in values[:index] + ): + reject(f"{name}: enum values must be distinct.") + try: + size = len( + json.dumps( + value, ensure_ascii=False, allow_nan=False, separators=(",", ":") + ).encode() + ) + except (ValueError, TypeError) as error: + raise GoalConfigurationError( + "unsupported_goal_interface", + path, + "The published schema contains invalid JSON values.", + ) from error + if size > 32768: + reject("The published schema exceeds the supported 32 KiB profile.") + return value + + +def goal_interface(value: Any, goal_id: str | None = None) -> dict[str, Any]: + goal = _object(value, "goal") + spec = goal.get("published_run_spec") + if ( + goal.get("object") != "goal" + or goal.get("status") != "active" + or not isinstance(goal.get("id"), str) + or not goal["id"] + ): + raise GoalConfigurationError( + "unsupported_goal_interface", + "goal", + "Select an active published Goal returned by the Goal API.", + ) + if goal_id is not None and goal["id"] != goal_id: + raise GoalConfigurationError( + "goal_mismatch", + "goal.id", + "The Goal response does not match the selected Goal.", + ) + if ( + not isinstance(spec, dict) + or not isinstance(spec.get("id"), str) + or not spec["id"] + or type(spec.get("version")) is not int + or spec["version"] < 1 + ): + raise GoalConfigurationError( + "unsupported_goal_interface", + "published_run_spec", + "The Goal must expose its immutable published RunSpec ID and positive integer version.", + ) + _schema(spec.get("input_schema"), "input_schema") + _schema(spec.get("result_schema"), "result_schema", result=True) + return goal + + +def _fields(schema: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "name": name, + "type": " | ".join(prop["type"]) + if isinstance(prop["type"], list) + else prop["type"], + "required": name in schema.get("required", []), + "description": prop.get("description", ""), + "default_json": json.dumps(prop["default"], ensure_ascii=False) + if "default" in prop + else "", + "enum_json": json.dumps(prop["enum"], ensure_ascii=False) + if "enum" in prop + else "", + } + for name, prop in schema["properties"].items() + ] + + +def _changes(before: dict[str, Any], after: dict[str, Any]) -> list[dict[str, str]]: + changes = [] + if before["id"] != after["id"]: + changes.append( + { + "path": "goal.id", + "message": "The reviewed interface belongs to a different Goal.", + } + ) + old, new = before["published_run_spec"], after["published_run_spec"] + if old["id"] != new["id"] or old["version"] != new["version"]: + changes.append( + { + "path": "published_run_spec", + "message": f"Published RunSpec changed from {old['id']} (version {old['version']}) to {new['id']} (version {new['version']}).", + } + ) + for section in ("input_schema", "result_schema"): + previous, current = old[section], new[section] + for name in sorted(set(previous["properties"]) | set(current["properties"])): + left, right = ( + previous["properties"].get(name), + current["properties"].get(name), + ) + if json.dumps(left, sort_keys=True) != json.dumps( + right, sort_keys=True + ) or ( + (name in previous.get("required", [])) + != (name in current.get("required", [])) + ): + change = ( + "added" + if left is None + else "removed" + if right is None + else "changed" + ) + changes.append( + { + "path": f"{section}.{name}", + "message": f"Field {change}; review its mapping, type, required flag, default, and enum.", + } + ) + return changes + + +def prepare_configuration( + goal: dict[str, Any], variables: Any, reviewed: Any +) -> dict[str, Any]: + goal = goal_interface(goal) + spec = goal["published_run_spec"] + errors = [] + changes = [] + effective = {} + try: + submitted = _object(variables, "variables_json") + schema = spec["input_schema"] + properties = schema["properties"] + effective = { + name: prop["default"] + for name, prop in properties.items() + if "default" in prop + } + effective.update(submitted) + for name in sorted(set(submitted) - set(properties)): + errors.append( + { + "code": "unknown_field", + "path": f"variables.{name}", + "message": "This field is not declared by the published Goal.", + } + ) + for name in sorted(set(schema.get("required", [])) - set(effective)): + errors.append( + { + "code": "missing_required", + "path": f"variables.{name}", + "message": "Map this required field from your upstream business record.", + } + ) + for name in properties.keys() & effective.keys(): + value, prop = effective[name], properties[name] + if not _matches(value, prop["type"]): + errors.append( + { + "code": "type_mismatch", + "path": f"variables.{name}", + "message": f"Expected a finite {prop['type']} value.", + } + ) + elif "enum" in prop and not any( + _same_value(value, option) for option in prop["enum"] + ): + errors.append( + { + "code": "enum_mismatch", + "path": f"variables.{name}", + "message": "The value is not a published enum option.", + } + ) + except GoalConfigurationError as error: + errors.append(error.finding) + if reviewed is None or reviewed == "": + errors.append( + { + "code": "review_required", + "path": "reviewed_goal_json", + "message": "Review the fields below, then save goal_json in Reviewed Goal interface. Keep that saved value until you review a new publication.", + } + ) + else: + try: + baseline = goal_interface(reviewed) + changes = _changes(baseline, goal) + if changes: + errors.append( + { + "code": "goal_interface_changed", + "path": "reviewed_goal_json", + "message": "The published Goal differs from the reviewed interface. Review the affected input and result mappings before starting a new business event.", + } + ) + except GoalConfigurationError as error: + errors.append( + {**error.finding, "path": "reviewed_goal_json." + error.finding["path"]} + ) + ready = not errors + result = { + "ready": ready, + # Empty routing outputs also stop an accidentally ungated creation node. + "goal_id": goal["id"] if ready else "", + "run_spec_id": spec["id"], + "run_spec_version": spec["version"], + "goal_json": json.dumps(goal, ensure_ascii=False, sort_keys=True), + "input_fields": _fields(spec["input_schema"]), + "result_fields": _fields(spec["result_schema"]), + "variables_json": json.dumps( + effective, ensure_ascii=False, sort_keys=True, allow_nan=False + ) + if ready + else "", + "errors": errors, + "changes": changes, + } + lines = [ + f"Goal: {goal.get('title') or goal['id']} ({goal['id']})", + f"Published RunSpec: {spec['id']}, version {spec['version']}", + ] + for heading, field_list in ( + ("Inputs", result["input_fields"]), + ("Results", result["result_fields"]), + ): + lines.append(heading + ":") + for field in field_list: + label = "required" if field["required"] else "optional" + detail = ( + f"- {field['name']}: {field['type']}, {label}. {field['description']}" + ) + if field["default_json"]: + detail += " Default: " + field["default_json"] + if field["enum_json"]: + detail += " Enum: " + field["enum_json"] + lines.append(detail) + lines.append( + "Ready for a new Goal Run." + if ready + else "Configuration needs attention; no Goal Run was created." + ) + lines.extend(f"- {item['path']}: {item['message']}" for item in changes + errors) + result["report"] = "\n".join(lines) + return result