diff --git a/.difyignore b/.difyignore index 43b009e..caa362d 100644 --- a/.difyignore +++ b/.difyignore @@ -6,7 +6,7 @@ __pycache__/ .DS_Store .env *.difypkg -.git/ +.git .gitignore .difyignore *.egg-info/ diff --git a/PRIVACY.md b/PRIVACY.md index 297c714..22b0d49 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -18,7 +18,7 @@ The plugin does not intentionally send Dify API keys, Dify workspace secrets, or CALL-E may return call status, recipient status, attempt status, transcript turns, summaries, structured results, evidence, metadata, and raw call result fields. -The plugin masks phone numbers in text and JSON responses before returning data to Dify. +The plugin masks destination phone fields and phone fields in metadata and error details before returning data to Dify. Call business results retain their original values and types, including phone-named fields, in `result`, `structured_result`, and `raw_call_result.result`. Results, summaries, and transcript text may contain personal data and are not anonymized. ## Storage diff --git a/README.md b/README.md index faa9fb8..d86b7c7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Otherwise it returns a preview with the phone number masked. Both Call creation tools require a business-stable `idempotency_key` of 1-255 printable ASCII characters, such as `order-8472-confirmation-attempt-1`. Save it with the original inputs before invoking the tool. Retrying the same key and unchanged inputs recovers the same Call; a new key means a new call. The plugin does not generate keys or retry creation automatically. -The plugin sends one top-level `phone` to `POST /v2/calls` with a closed, flat scalar `result_schema`. The built-in schema requests `call_completed`, `outcome`, `summary`, and `next_action`, with optional observation fields. Include missing-answer rules in the task consistent with these fields. Leave `region` and `locale` blank for API inference, and keep them blank on replay even when the response includes inferred values. +The plugin sends one top-level `phone` to `POST /v2/calls` with a closed, flat scalar `result_schema`. Leave **Result Schema JSON** blank to keep the built-in fields: `call_completed`, `outcome`, `summary`, and `next_action`, with optional observation fields. Both `create_call` and `create_and_wait` accept a custom schema instead. Include missing-answer rules in the task consistent with the requested fields. Leave `region` and `locale` blank for API inference, and keep them blank on replay even when the response includes inferred values. `live_call_created=true` reports an accepted resource, including an exact replay. It does not prove a new dial or a successful connection. HTTP creation errors return `error.code`, `error.message`, `error.details`, and the supplied key for recovery. @@ -73,6 +73,29 @@ Creates one call, then polls while `result_status=pending`. The waiter stops for A wait timeout returns `timed_out=true`, the same `call_id`, and the supplied key. A polling error returns `accepted_resource` with the last saved ID; use `get_call` to resume. A network failure during creation can leave acceptance uncertain: retry only the original key and unchanged inputs. An expired Dify host execution also does not cancel the call. Set the host's plugin execution timeout to cover the requested wait, or use `create_call` followed by `get_call` in later workflow steps. +### Custom result fields + +Enter a JSON Schema object in **Result Schema JSON**. It replaces the built-in schema; the plugin does not add default business fields. Existing workflows that omit this input keep their current request and output paths. + +The schema must have `type: "object"`, `additionalProperties: false`, and at most 32 flat fields. Each field supports `string`, `boolean`, `integer`, or `number`, an optional `description`, and an optional `enum`. Field names must match `[a-z][a-z0-9_]{0,63}`. `required` may list declared fields. Descriptions have a 512-character limit, enums allow 1–32 distinct values of the field's type, and the schema has a 32 KiB JSON limit. Nested objects, arrays, nullable fields, references, and other validation keywords are unsupported. Invalid schemas stop locally before any CALL-E request, including in dry run mode. + +For unanswered questions, use an explicit value such as `unknown` and explain it in the task. Do not use a boolean when you need to distinguish “no” from “unknown.” Save the schema with the original task, phone, optional inputs, and idempotency key. Replays must use that same request; editing the schema under an existing key is not a retry. + +Business fields remain in the returned result object, including `false`, `0`, `{}`, and `null`: + +| Tool | Business fields | Readiness and telephone outcome | +| --- | --- | --- | +| `create_call`, `create_and_wait` | `result.result` | `result.result_status`, `result.call_outcome`, `result.error` | +| `get_call` | `result` (also `structured_result`) | `result_status`, `call_outcome`, `error` | + +In Dify, bind the result object to a Code node to read a custom key. The variable picker lists the built-in fields; custom names are not added to it automatically. The complete `json` output is an array containing the tool response. A `completed` telephone outcome or an `available` result alone does not mean the recipient confirmed anything. Require the explicit business value as well. Keep empty, unavailable, and unknown answers in a separate branch. + +#### Appointment confirmation example + +Import [appointment-confirmation.yml](examples/appointment-confirmation.yml) into Dify after installing this plugin and configuring its credentials. The workflow requests `appointment_status` and `preferred_time`, reads those fields in a Code node, and uses an If/Else node to select **Confirmed**, **Reschedule requested**, or **Manual review**. It does not interpret the call summary with another model or book a replacement appointment. + +The example starts in dry run mode. Supply `phone_number`, `appointment_time`, and a `call_request_id` saved by your upstream application. Inspect the preview, then set the tool's `dry_run=false` and `confirm_live_call=true` only for an authorized recipient. Preserve these inputs and the schema when rerunning that same business event. If waiting times out, retain `call_id` and resume with `get_call`; do not create a replacement call. + ### Goal tools `list_goals` and `get_goal` read the published Goal catalog. They do not create a call. Pass the previous page's `next_cursor` unchanged as `after` for the next page. @@ -105,7 +128,7 @@ See the [migration guide](https://docs.heycall-e.com/migration), [Calls contract - Requires E.164 destination phone numbers, for example `+15555550123`. - Rejects common placeholder numbers for live calls. -- Masks phone numbers in text and JSON responses. +- Masks destination phone fields and phone fields in metadata and error details. Call business results retain their original values and types; results and transcript text may contain personal data. - Does not create recurring schedules. - Does not expose API keys in logs or responses. - Defaults to `dry_run=true`. diff --git a/examples/appointment-confirmation.yml b/examples/appointment-confirmation.yml new file mode 100644 index 0000000..861c5f4 --- /dev/null +++ b/examples/appointment-confirmation.yml @@ -0,0 +1,151 @@ +app: + name: CALL-E appointment confirmation + description: Confirm an appointment using custom result fields. 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-call, source: start, sourceHandle: source, target: call, targetHandle: target, type: custom, data: {sourceType: start, targetType: tool}} + - {id: call-fields, source: call, sourceHandle: source, target: fields, targetHandle: target, type: custom, data: {sourceType: tool, targetType: code}} + - {id: fields-route, source: fields, sourceHandle: source, target: route, targetHandle: target, type: custom, data: {sourceType: code, targetType: if-else}} + - {id: route-confirmed, source: route, sourceHandle: confirmed, target: confirmed, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: end}} + - {id: route-reschedule, source: route, sourceHandle: reschedule_requested, target: reschedule, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: end}} + - {id: route-review, source: route, sourceHandle: 'false', target: review, targetHandle: target, type: custom, data: {sourceType: if-else, targetType: end}} + nodes: + - id: start + type: custom + position: {x: 0, y: 160} + data: + type: start + title: Saved appointment request + variables: + - {variable: phone_number, label: Authorized phone number, type: text-input, required: true, max_length: 16} + - {variable: appointment_time, label: Appointment time, type: text-input, required: true, max_length: 200} + - {variable: call_request_id, label: Saved call request ID, type: text-input, required: true, max_length: 255} + - id: call + type: custom + position: {x: 300, y: 160} + data: + type: tool + title: Confirm appointment + 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_and_wait + tool_label: Create And Wait For CALL-E Call + tool_node_version: '2' + tool_configurations: {} + tool_parameters: + phone_number: {type: mixed, value: '{{#start.phone_number#}}'} + idempotency_key: {type: mixed, value: '{{#start.call_request_id#}}'} + dry_run: {type: constant, value: true} + confirm_live_call: {type: constant, value: false} + task: + type: mixed + value: >- + Speak English. Ask whether the recipient confirms the appointment for + {{#start.appointment_time#}}. Report appointment_status=confirmed only + after an explicit confirmation of that appointment. If they request a + different time, report reschedule_requested and ask for preferred_time; + do not book or promise a new appointment. If they do not answer clearly, + are not the intended recipient, or the call does not establish their + decision, report unknown. Set preferred_time to unknown unless they + explicitly provide a different time. Thank them and end politely. + result_schema: + type: mixed + value: >- + {"type":"object","additionalProperties":false,"required":["appointment_status","preferred_time"],"properties":{"appointment_status":{"type":"string","enum":["confirmed","reschedule_requested","unknown"],"description":"The recipient's explicit decision; unknown when it was not established."},"preferred_time":{"type":"string","description":"Requested alternative time, or unknown when none was provided."}}} + poll_interval_seconds: {type: constant, value: 5} + wait_timeout_minutes: {type: constant, value: 4} + - id: fields + type: custom + position: {x: 600, y: 160} + data: + type: code + title: Read appointment fields + code_language: python3 + variables: + - {variable: tool_output, value_selector: [call, json]} + outputs: + route: {type: string, children: null} + appointment_status: {type: string, children: null} + preferred_time: {type: string, children: null} + code: | + def main(tool_output: list) -> dict: + envelope = tool_output[0] if tool_output and isinstance(tool_output[0], dict) else {} + call = envelope.get("result") + call = call if isinstance(call, dict) else {} + result = call.get("result") + result = result if isinstance(result, dict) else {} + status = result.get("appointment_status", "unknown") + if status not in ("confirmed", "reschedule_requested", "unknown"): + status = "unknown" + preferred_time = result.get("preferred_time", "unknown") + if not isinstance(preferred_time, str) or not preferred_time.strip(): + preferred_time = "unknown" + ready = ( + call.get("result_status") == "available" + and call.get("call_outcome") == "completed" + and call.get("error") is None + ) + route = status if ready and status != "unknown" else "manual_review" + return {"route": route, "appointment_status": status, "preferred_time": preferred_time} + - id: route + type: custom + position: {x: 900, y: 160} + data: + type: if-else + title: Appointment decision + cases: + - case_id: confirmed + logical_operator: and + conditions: + - {id: is-confirmed, variable_selector: [fields, route], varType: string, comparison_operator: is, value: confirmed} + - case_id: reschedule_requested + logical_operator: and + conditions: + - {id: is-reschedule, variable_selector: [fields, route], varType: string, comparison_operator: is, value: reschedule_requested} + - id: confirmed + type: custom + position: {x: 1200, y: 0} + data: + type: end + title: Confirmed + outputs: + - {variable: route, value_selector: [fields, route], value_type: string} + - {variable: appointment_status, value_selector: [fields, appointment_status], value_type: string} + - {variable: preferred_time, value_selector: [fields, preferred_time], value_type: string} + - {variable: call_response, value_selector: [call, json], value_type: 'array[object]'} + - id: reschedule + type: custom + position: {x: 1200, y: 160} + data: + type: end + title: Reschedule requested + outputs: + - {variable: route, value_selector: [fields, route], value_type: string} + - {variable: appointment_status, value_selector: [fields, appointment_status], value_type: string} + - {variable: preferred_time, value_selector: [fields, preferred_time], value_type: string} + - {variable: call_response, value_selector: [call, json], value_type: 'array[object]'} + - id: review + type: custom + position: {x: 1200, y: 320} + data: + type: end + title: Manual review + outputs: + - {variable: route, value_selector: [fields, route], value_type: string} + - {variable: appointment_status, value_selector: [fields, appointment_status], value_type: string} + - {variable: preferred_time, value_selector: [fields, preferred_time], value_type: string} + - {variable: call_response, value_selector: [call, json], value_type: 'array[object]'} diff --git a/manifest.yaml b/manifest.yaml index ec55599..6eeed0a 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -1,4 +1,4 @@ -version: 0.2.2 +version: 0.3.0 type: plugin author: derekwang2002 name: call_e diff --git a/pyproject.toml b/pyproject.toml index 8e4c357..0deb473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "call-e-dify-plugin" -version = "0.2.2" +version = "0.3.0" description = "CALL-E tools for Dify workflows and agents" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_appointment_example.py b/tests/test_appointment_example.py new file mode 100644 index 0000000..06c31c6 --- /dev/null +++ b/tests/test_appointment_example.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import pytest +import yaml + +from utils.client import parse_result_schema + + +EXAMPLE = yaml.safe_load((Path(__file__).resolve().parents[1] / "examples/appointment-confirmation.yml").read_text()) +NODES = {node["id"]: node["data"] for node in EXAMPLE["workflow"]["graph"]["nodes"]} + + +def branch(call): + namespace = {} + exec(NODES["fields"]["code"], namespace) + return namespace["main"]([{"result": call}]) + + +def test_example_uses_custom_fields_and_starts_without_dialing(): + parameters = NODES["call"]["tool_parameters"] + schema = parse_result_schema(parameters["result_schema"]["value"]) + assert schema["required"] == ["appointment_status", "preferred_time"] + assert parameters["dry_run"]["value"] is True + assert parameters["confirm_live_call"]["value"] is False + assert parameters["idempotency_key"]["value"] == "{{#start.call_request_id#}}" + + +@pytest.mark.parametrize("status", ["confirmed", "reschedule_requested", "unknown"]) +def test_example_reads_business_fields_and_selects_the_matching_branch(status): + output = branch({"result_status": "available", "call_outcome": "completed", "error": None, + "result": {"appointment_status": status, "preferred_time": "Tuesday at 10"}}) + assert output["appointment_status"] == status + assert output["preferred_time"] == "Tuesday at 10" + assert output["route"] == (status if status != "unknown" else "manual_review") + cases = {case["case_id"] for case in NODES["route"]["cases"]} + assert (output["route"] in cases) == (status != "unknown") + + +@pytest.mark.parametrize("call", [ + None, {}, + {"result_status": "available", "call_outcome": "completed", "result": {}}, + {"result_status": "unavailable", "call_outcome": "completed", "result": None, "error": None}, + {"result_status": "pending", "call_outcome": "completed", "result": {"appointment_status": "confirmed"}}, + {"result_status": "available", "call_outcome": "failed", "result": {"appointment_status": "confirmed"}}, + {"result_status": "available", "call_outcome": "completed", "error": {"code": "failed"}, + "result": {"appointment_status": "confirmed"}}, + {"result_status": "available", "call_outcome": "completed", "result": {"appointment_status": ["confirmed"]}}, +]) +def test_example_never_treats_missing_or_uncertain_evidence_as_confirmation(call): + assert branch(call)["route"] == "manual_review" diff --git a/tests/test_client.py b/tests/test_client.py index 86e155d..ffbaff5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import pytest from utils import client @@ -38,6 +40,41 @@ def test_mask_phone_hides_middle_digits(): assert mask_phone("+15555550123") == "+15****23" +def test_parse_call_result_preserves_business_values_and_masks_envelope_phones(): + result = { + "phone_number_confirmed": False, + "phone_number_attempts": 0, + "phone": "unknown", + "callback_phone_number": "+15555550123", + } + call = { + "id": "call_1", + "phone": "+15555550123", + "result": result, + "metadata": {"phone_number": "+15555550123"}, + "error": {"details": {"phone": "+15555550123"}}, + } + original = deepcopy(call) + + parsed = parse_call_result(call) + + assert parsed["result"] == result + assert parsed["structured_result"] == result + assert parsed["raw_call_result"]["result"] == result + assert parsed["raw_call_result"]["phone"] == "+15****23" + assert parsed["metadata_returned"]["phone_number"] == "+15****23" + assert parsed["error"]["details"]["phone"] == "+15****23" + assert call == original + + +def test_parse_call_result_keeps_missing_raw_result_absent(): + parsed = parse_call_result({"id": "call_1"}) + + assert parsed["result"] is None + assert parsed["structured_result"] is None + assert "result" not in parsed["raw_call_result"] + + def test_parse_json_object_requires_object(): assert parse_json_object('{"lead_id":"lead_1"}', field_name="metadata_json") == { "lead_id": "lead_1" diff --git a/tests/test_plugin_metadata.py b/tests/test_plugin_metadata.py index f393ac1..7aac1ce 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.2.2" + assert manifest["version"] == "0.3.0" assert pyproject["project"]["version"] == manifest["version"] diff --git a/tests/test_result_schema.py b/tests/test_result_schema.py new file mode 100644 index 0000000..dc5b59e --- /dev/null +++ b/tests/test_result_schema.py @@ -0,0 +1,106 @@ +import copy +import json + +import pytest + +from utils.client import build_create_payload, parse_result_schema, result_schema + + +SCHEMA = { + "type": "object", + "additionalProperties": False, + "properties": { + "appointment_status": {"type": "string", "enum": ["confirmed", "reschedule_requested", "unknown"]}, + "preferred_time": {"type": "string", "description": "Requested time, or unknown."}, + "accepted": {"type": "boolean"}, + "party_size": {"type": "integer", "enum": [0, 2]}, + "duration": {"type": "number", "enum": [0, 0.5]}, + }, + "required": ["appointment_status"], +} + + +@pytest.mark.parametrize("value", [None, "", " \n\t"]) +def test_blank_schema_keeps_the_existing_request(value): + payload = build_create_payload(phone_number="+14155550100", task="Speak English.", + region="", locale="", metadata={}, schema=value) + assert payload["result_schema"] == result_schema() + assert "region" not in payload and "locale" not in payload + + +@pytest.mark.parametrize("as_json", [False, True]) +def test_custom_schema_is_preserved_without_merging_defaults(as_json): + schema = copy.deepcopy(SCHEMA) + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + value = json.dumps(schema) if as_json else schema + parsed = parse_result_schema(value) + assert parsed == schema + assert "outcome" not in parsed["properties"] + assert parsed["required"] == ["appointment_status"] + + +@pytest.mark.parametrize("count", [0, 32]) +def test_empty_and_maximum_size_closed_objects_are_supported(count): + schema = {"type": "object", "additionalProperties": False, + "properties": {f"field_{i}": {"type": "string"} for i in range(count)}} + assert parse_result_schema(schema) == schema + + +@pytest.mark.parametrize("value", [ + "{", "[]", "null", "true", "42", {}, [], + '{"type":"object","type":"string"}', + '{"type":"object","additionalProperties":false,"properties":{"a":{"type":"string"},"a":{"type":"number"}}}', +]) +def test_invalid_json_and_root_shapes_are_rejected(value): + with pytest.raises(ValueError, match="result_schema"): + parse_result_schema(value) + + +@pytest.mark.parametrize("change", [ + {"additionalProperties": True}, {"additionalProperties": 0}, {"type": "array"}, + {"properties": None}, {"properties": []}, + {"properties": {f"f{i}": {"type": "string"} for i in range(33)}}, + {"required": "appointment_status"}, {"required": ["missing"]}, + {"required": ["appointment_status", "appointment_status"]}, {"required": [{}]}, + {"oneOf": []}, {"$ref": "#/other"}, {"title": "Unsupported"}, + {"description": "x" * 513}, {"description": 12}, + {"$schema": "http://json-schema.org/draft-07/schema#"}, +]) +def test_invalid_root_contract_is_rejected(change): + with pytest.raises(ValueError, match="result_schema"): + parse_result_schema({**SCHEMA, **change}) + + +@pytest.mark.parametrize("property_schema", [ + {}, False, {"type": "object"}, {"type": "array"}, {"type": "null"}, + {"type": ["string", "null"]}, {"type": "string", "default": "unknown"}, + {"type": "string", "minLength": 1}, {"type": "string", "pattern": "yes"}, + {"type": "integer", "minimum": 0}, {"type": "string", "anyOf": []}, + {"type": "string", "description": []}, {"type": "string", "description": "x" * 513}, + {"type": "string", "enum": []}, {"type": "string", "enum": "yes"}, + {"type": "string", "enum": ["yes", "yes"]}, {"type": "string", "enum": [None]}, + {"type": "string", "enum": [{}]}, {"type": "string", "enum": list(map(str, range(33)))}, + {"type": "boolean", "enum": [1]}, {"type": "integer", "enum": [True]}, + {"type": "integer", "enum": [1.5]}, {"type": "integer", "enum": [2 ** 53]}, + {"type": "number", "enum": [float("nan")]}, {"type": "number", "enum": [float("inf")]}, + {"type": "number", "enum": [1, 1.0]}, +]) +def test_unsupported_property_contract_is_rejected(property_schema): + with pytest.raises(ValueError, match="result_schema"): + parse_result_schema({"type": "object", "additionalProperties": False, + "properties": {"answer": property_schema}}) + + +@pytest.mark.parametrize("name", ["", "Upper", "a-b", "a.b", "1answer", "a" * 65, 1]) +def test_invalid_field_names_are_rejected(name): + with pytest.raises(ValueError, match="result_schema"): + parse_result_schema({"type": "object", "additionalProperties": False, + "properties": {name: {"type": "string"}}}) + + +def test_schema_byte_limit_is_checked_with_unicode(): + schema = {"type": "object", "additionalProperties": False, "properties": { + f"field_{i}": {"type": "string", "description": "中" * 512} for i in range(32) + }} + with pytest.raises(ValueError, match="32 KiB"): + parse_result_schema(schema) diff --git a/tests/test_tools.py b/tests/test_tools.py index 980b082..480cbc3 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -180,3 +180,68 @@ def test_get_call_emits_v2_fields_as_named_variables(monkeypatch): assert variables == json_output(messages) assert variables["call_id"] == "call_1" and variables["billing_call_id"] is None assert variables["result_status"] == "unavailable" and variables["result"] is None + + +@pytest.mark.parametrize("tool_class", [CreateCallTool, CreateAndWaitTool]) +@pytest.mark.parametrize("live", [False, True]) +def test_invalid_custom_schema_is_rejected_before_any_network_request(tool_class, live, monkeypatch): + def unexpected_request(*args, **kwargs): + pytest.fail("An invalid result schema must not access the network") + monkeypatch.setattr(client.requests, "request", unexpected_request) + messages = invoke(tool_class, {**PARAMETERS, "result_schema": '{"type":"object"}', + "dry_run": not live, "confirm_live_call": live}) + assert any("result_schema" in message["message"].get("text", "") for message in messages) + assert not any(message["type"] == "json" for message in messages) + + +@pytest.mark.parametrize("tool_class", [CreateCallTool, CreateAndWaitTool]) +def test_custom_schema_preview_and_recovery_preserve_the_original_request(tool_class, monkeypatch): + schema = {"type": "object", "additionalProperties": False, "properties": { + "appointment_status": {"type": "string", "enum": ["confirmed", "unknown"]}, + }, "required": ["appointment_status"]} + parameters = {**PARAMETERS, "result_schema": json.dumps(schema)} + preview = json_output(invoke(tool_class, parameters)) + assert preview["result_schema"] == schema + requests = [] + class Response: + status_code = 202 + text = "{}" + def json(self): + return {"id": "call_saved", "status": "completed", "call_outcome": "completed", + "result_status": "available", "result": {"appointment_status": "unknown"}, "error": None} + def request(method, url, **kwargs): + requests.append((method, url, kwargs)) + if len(requests) == 1: + raise client.requests.Timeout("Lost accepted response") + return Response() + monkeypatch.setattr(client.requests, "request", request) + parameters.update(dry_run=False, confirm_live_call=True) + uncertain = json_output(invoke(tool_class, parameters)) + recovered_messages = invoke(tool_class, parameters) + recovered = json_output(recovered_messages) + assert len(requests) == 2 and requests[0] == requests[1] + assert requests[0][2]["json"]["result_schema"] == schema + assert uncertain["accepted_resource"] is None + assert recovered["call_id"] == "call_saved" + assert recovered["result"]["result"] == {"appointment_status": "unknown"} + variables = {m["message"]["variable_name"]: m["message"]["variable_value"] + for m in recovered_messages if m["type"] == "variable"} + assert variables["result"] == recovered["result"] + + +@pytest.mark.parametrize("result,status,outcome", [ + ({"appointment_status": "unknown", "accepted": False, "party_size": 0}, "available", "completed"), + ({}, "available", "completed"), + (None, "unavailable", "no_answer"), +]) +def test_get_call_preserves_custom_fields_and_unavailable_results(result, status, outcome, monkeypatch): + from tools.get_call import GetCallTool + + monkeypatch.setattr(client.CalleClient, "get_call", lambda *a: { + "id": "call_1", "status": "completed", "call_outcome": outcome, + "result_status": status, "result": result, "error": None, + }) + output = json_output(invoke(GetCallTool, {"call_id": "call_1"})) + assert output["result"] == output["structured_result"] == result + assert output["result_status"] == status and output["call_outcome"] == outcome + assert output["error"] is None and output["summary"] is None diff --git a/tools/create_and_wait.py b/tools/create_and_wait.py index c3acf83..c615b76 100644 --- a/tools/create_and_wait.py +++ b/tools/create_and_wait.py @@ -59,6 +59,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag region=region, locale=locale, metadata=metadata, + schema=tool_parameters.get("result_schema"), ) preview = { "dry_run": dry_run, @@ -70,6 +71,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "locale": locale, "task": task, "metadata": redact_phone_fields(payload["metadata"]), + "result_schema": payload["result_schema"], "poll_interval_seconds": poll_interval_seconds, "wait_timeout_minutes": wait_timeout_minutes, } diff --git a/tools/create_and_wait.yaml b/tools/create_and_wait.yaml index c33b54f..7162ce9 100644 --- a/tools/create_and_wait.yaml +++ b/tools/create_and_wait.yaml @@ -73,6 +73,15 @@ parameters: human_description: en_US: Optional spoken language. CALL-E infers it from the task when omitted. llm_description: Optional locale hint, for example en-US or pt-BR. Omit to infer from the task. + - name: result_schema + type: string + required: false + form: llm + label: + en_US: Result Schema JSON + human_description: + en_US: Optional closed JSON Schema object with up to 32 scalar fields. Leave blank for the default result fields. + llm_description: Optional JSON object with type=object and additionalProperties=false. Fields support string, boolean, integer, or number, with description and enum. Use an explicit unknown value when appropriate. Preserve this schema and all other inputs on retries. - name: metadata_json type: string required: false @@ -131,3 +140,25 @@ output_schema: type: string summary: type: [string, "null"] + call_outcome: + type: [string, "null"] + error: + type: [object, "null"] + properties: + code: + type: string + message: + type: string + result: + type: [object, "null"] + description: Business fields from the requested result schema, or null when unavailable. + additionalProperties: true + properties: + summary: + type: string + call_completed: + type: string + outcome: + type: string + next_action: + type: string diff --git a/tools/create_call.py b/tools/create_call.py index 1911b81..cd6086f 100644 --- a/tools/create_call.py +++ b/tools/create_call.py @@ -45,6 +45,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag region=region, locale=locale, metadata=metadata, + schema=tool_parameters.get("result_schema"), ) preview = { "dry_run": dry_run, @@ -56,6 +57,7 @@ def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessag "locale": locale, "task": task, "metadata": redact_phone_fields(payload["metadata"]), + "result_schema": payload["result_schema"], } if dry_run or not confirm_live_call: diff --git a/tools/create_call.yaml b/tools/create_call.yaml index 5291a6c..01d3f87 100644 --- a/tools/create_call.yaml +++ b/tools/create_call.yaml @@ -73,6 +73,15 @@ parameters: human_description: en_US: Optional spoken language. CALL-E infers it from the task when omitted. llm_description: Optional locale hint, for example en-US or pt-BR. Omit to infer from the task. + - name: result_schema + type: string + required: false + form: llm + label: + en_US: Result Schema JSON + human_description: + en_US: Optional closed JSON Schema object with up to 32 scalar fields. Leave blank for the default result fields. + llm_description: Optional JSON object with type=object and additionalProperties=false. Fields support string, boolean, integer, or number, with description and enum. Use an explicit unknown value when appropriate. Preserve this schema and all other inputs on retries. - name: metadata_json type: string required: false @@ -107,3 +116,25 @@ output_schema: type: string summary: type: [string, "null"] + call_outcome: + type: [string, "null"] + error: + type: [object, "null"] + properties: + code: + type: string + message: + type: string + result: + type: [object, "null"] + description: Business fields from the requested result schema, or null when unavailable. + additionalProperties: true + properties: + summary: + type: string + call_completed: + type: string + outcome: + type: string + next_action: + type: string diff --git a/tools/get_call.yaml b/tools/get_call.yaml index fa86026..e245da7 100644 --- a/tools/get_call.yaml +++ b/tools/get_call.yaml @@ -46,6 +46,8 @@ output_schema: type: array structured_result: &business_result type: [object, "null"] + description: Business fields from the requested result schema, or null when unavailable. + additionalProperties: true properties: summary: type: string diff --git a/utils/client.py b/utils/client.py index 6f87e9d..d06be6d 100644 --- a/utils/client.py +++ b/utils/client.py @@ -206,6 +206,75 @@ def result_schema() -> dict[str, Any]: } +def parse_result_schema(value: Any) -> dict[str, Any]: + if value is None or (isinstance(value, str) and not value.strip()): + return result_schema() + + def unique_object(pairs): + parsed = dict(pairs) + if len(parsed) != len(pairs): + raise ValueError("result_schema must not repeat JSON object keys.") + return parsed + + if isinstance(value, str): + try: + value = json.loads(value, object_pairs_hook=unique_object) + except json.JSONDecodeError as error: + raise ValueError("result_schema must be a valid JSON object string.") from error + if not isinstance(value, dict): + raise ValueError("result_schema must be a JSON object.") + root_keywords = {"$schema", "type", "description", "additionalProperties", "properties", "required"} + if set(value) - root_keywords: + raise ValueError("result_schema contains unsupported root keywords.") + if value.get("$schema") not in (None, "https://json-schema.org/draft/2020-12/schema"): + raise ValueError("result_schema.$schema must use JSON Schema draft 2020-12.") + if value.get("type") != "object" or value.get("additionalProperties") is not False: + raise ValueError("result_schema requires type=object and additionalProperties=false.") + properties = value.get("properties") + if not isinstance(properties, dict) or len(properties) > 32: + raise ValueError("result_schema.properties must be an object with at most 32 fields.") + required = value.get("required", []) + if ( + not isinstance(required, list) + or any(not isinstance(name, str) or name not in properties for name in required) + or len(set(required)) != len(required) + ): + raise ValueError("result_schema.required must list declared field names without duplicates.") + for node in [value, *properties.values()]: + if not isinstance(node, dict): + raise ValueError("Each result_schema property must be a scalar schema object.") + description = node.get("description") + if description is not None and (not isinstance(description, str) or len(description) > 512): + raise ValueError("result_schema descriptions must contain at most 512 characters.") + scalar_types = {"string": (str,), "boolean": (bool,), "integer": (int,), "number": (int, float)} + for name, schema in properties.items(): + if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", name): + raise ValueError("result_schema field names must match [a-z][a-z0-9_]{0,63}.") + if set(schema) - {"type", "description", "enum"}: + raise ValueError(f"result_schema.properties.{name} only supports type, description, and enum.") + kind = schema.get("type") + if not isinstance(kind, str) or kind not in scalar_types: + raise ValueError(f"result_schema.properties.{name} must be string, boolean, integer, or number.") + if "enum" in schema: + values = schema["enum"] + if not isinstance(values, list) or not 1 <= len(values) <= 32: + raise ValueError(f"result_schema.properties.{name}.enum must contain 1-32 values.") + if any( + type(item) not in scalar_types[kind] + or (isinstance(item, float) and not math.isfinite(item)) + or (type(item) is int and abs(item) > 9007199254740991) + for item in values + ): + raise ValueError(f"result_schema.properties.{name}.enum values must match its finite scalar type.") + if len(set(values)) != len(values): + raise ValueError(f"result_schema.properties.{name}.enum must not contain duplicates.") + encoded = json.dumps({key: item for key, item in value.items() if key != "$schema"}, + ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > 32768: + raise ValueError("result_schema must not exceed 32 KiB of JSON.") + return value + + def build_create_payload( *, phone_number: str, @@ -213,11 +282,12 @@ def build_create_payload( region: str, locale: str, metadata: dict[str, Any], + schema: Any = None, ) -> dict[str, Any]: payload = { "task": task, "phone": validate_e164(phone_number), - "result_schema": result_schema(), + "result_schema": parse_result_schema(schema), "metadata": {**metadata, "source": "dify_call_e_plugin"}, } if region: @@ -240,6 +310,9 @@ def parse_call_result(call: dict[str, Any], *, metadata_sent: dict[str, Any] | N metadata_sent = metadata_sent or {} metadata_keys = sorted(metadata_sent) missing_or_different = [key for key in metadata_keys if metadata_sent.get(key) != metadata_returned.get(key)] + raw_call_result = redact_phone_fields(call) + if "result" in call: + raw_call_result["result"] = structured_result return { "id": extract_call_id(call), @@ -263,9 +336,9 @@ def parse_call_result(call: dict[str, Any], *, metadata_sent: dict[str, Any] | N }, "summary": as_object(structured_result).get("summary"), "transcript": redact_phone_fields(as_list(call.get("transcript"))), - "result": redact_phone_fields(structured_result), - "structured_result": redact_phone_fields(structured_result), - "raw_call_result": redact_phone_fields(call), + "result": structured_result, + "structured_result": structured_result, + "raw_call_result": raw_call_result, }