diff --git a/code_review.md b/code_review.md index f78e7bd6b..3d4e6bdfc 100644 --- a/code_review.md +++ b/code_review.md @@ -40,7 +40,7 @@ Guides load on demand, so the general passes here stay short. | Change type | Focus | Guide | |---|---|---| | `runtime/` state and recovery | serde and replay type fidelity, real failure-path tests | [review-guides/runtime-state-recovery.md](review-guides/runtime-state-recovery.md) | -| Python-Java bridge | cross-language parity, type mapping across Pemja | planned | +| Python-Java bridge | cross-language parity, type mapping across Pemja | [review-guides/python-java-bridge.md](review-guides/python-java-bridge.md) | | `api/` contract | API shape, compatibility policy, deprecation | planned | | `dist` and dependency | shading, LICENSE and NOTICE, dist registration | planned | | docs-only | facts match their source of truth | planned | diff --git a/dev/agent-skills/flink-agents-dev/SKILL.md b/dev/agent-skills/flink-agents-dev/SKILL.md new file mode 100644 index 000000000..2b5384537 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/SKILL.md @@ -0,0 +1,442 @@ +--- +name: flink-agents-dev +description: Use when building, scaffolding, modifying, debugging, converting, or verifying Apache Flink Agents applications, including Flink Agents YAML, Workflow Agent, ReAct Agent, Actions, Resources, MCP servers, vector stores, runtime skills, Python, or Java. Do not use for ordinary Flink jobs that do not use Flink Agents. +--- + +# Developing Flink Agents Applications + +## Core Model + +Model every application as four connected parts: + +1. **Resources**: named models, prompts, tools, skills, vector stores, MCP servers, + and connections. +2. **Actions**: event handlers that use Resources and emit events. +3. **Orchestration**: trigger conditions and emitted-event graph. +4. **Implementation**: Python/Java functions, types, and runner. + +Skills, vector stores, MCP, RAG, and memory are combinations of these parts. + +The Agent API language and custom Action/Tool language do not constrain every +Resource implementation. Chat-model connections/setups, embedding-model +connections/setups, and vector stores support Python/Java bridging in both +directions in the bundled snapshot. For those Resource types, offer every +target-version implementation supported natively or through the bridge, regardless +of whether the application uses YAML, direct Python, or direct Java. Treat the +selected implementation's language as part of that Resource choice; do not add a +separate application-wide cross-language confirmation. Do not generalize this rule +to Resource types that lack a verified bridge. + +## Source Authority + +This skill must work from its installed directory; never assume the user cloned the +Flink Agents repository. Resolve bundled paths relative to this `SKILL.md`. + +Inspect the target version and conventions. Use sources in this order: + +1. Target application code, dependency metadata, installed package/JAR APIs, and tests. +2. Version-matching Flink Agents schema, docs, examples, or source when available. +3. This skill's [YAML contract manifest](assets/yaml-contracts.yaml), matching + bundled schema, and references as the offline baseline. + +Select contracts by the target Flink Agents version, not by the version that +published this skill. The manifest maps released versions to complete schemas and +marks versions without YAML support. The unversioned +[main schema](assets/yaml-schema.json) describes only the repository revision that +published this skill. Never validate a released application against that schema +unless the manifest maps the selected version to it. + +Do not invent APIs, versions, or commands from memory. + +Before presenting code as runnable, resolve every nontrivial import, constructor, +method, descriptor argument, and dependency coordinate against those sources. If a +contract cannot be resolved, either use a documented alternative or label the +fragment as pseudocode and state exactly what remains unresolved. Apply +[Provider Contract Triage](#provider-contract-triage) below to external +descriptor-backed Providers before loading bundled references. This blocked +Provider path is different from an unresolved business contract, which still +receives a user-fillable skeleton. + +## Preflight + +Before loading a platform adapter, asking a decision gate, or generating files: + +1. Confirm that the target is a Flink Agents application or is being converted into + one. Leave ordinary Flink jobs outside this workflow. +2. Inspect the complete user request, existing project, dependency metadata, source, + configuration, and tests. Resolve every framework decision already answered by + that evidence. The latest explicit user choice takes precedence; treat a conflict + with existing project metadata as an intentional requested migration and report + it rather than silently restoring the old value. +3. Record each required framework decision as `confirmed` or `unresolved`: Flink + Agents version, Flink version, API, YAML application language when applicable, + each descriptor-backed Resource implementation, and the Python environment when + Python is required. +4. Run Provider Contract Triage for every concrete external descriptor-backed + Provider already named by the request or project. When the implementation is an + unresolved gate, run the triage immediately after the user selects it. +5. Build the ordered gate list from only the `unresolved` decisions. If the list is + empty, skip host detection and interaction adapters and proceed directly to + contract resolution and implementation. +6. For YAML, select the exact target-version schema through + [yaml-contracts.yaml](assets/yaml-contracts.yaml). Filter the API choices when the + manifest says that the selected version has no YAML API. If a requested YAML + version has no matching schema, stop YAML generation and request the exact + target-version schema or source; never substitute the main schema. + +Keep this decision record current after every answer. It prevents repeated +questions and ensures that later references operate only on confirmed versions and +capabilities. + +### Provider Contract Triage + +This is an early-exit pass, not a later Build Workflow step. Run it as soon as a +concrete external descriptor-backed Provider class or alias is confirmed and before +reading any file under `references/`. + +Inspect only evidence that can establish that Provider's contract: + +1. Explicit user-supplied JARs, packages, source, tests, or documentation. +2. The target project's dependency metadata and the exact installed artifact named + by that metadata. +3. Version-matching authoritative Provider source or documentation already available + at a known location. + +Scope every inspection command to the target application's dependency files, +configuration, source roots, and explicitly named artifacts. Exclude the installed +Skill directory containing this `SKILL.md`, including project-local `.agents/`, and +exclude host metadata such as `.codex/`, `.claude/`, tool caches, and unrelated home +or package caches. Do not run an unscoped recursive search over `.` when it would +traverse those paths. + +Do not load general application, YAML, language, local-development, or verification +references to compensate for a missing Provider artifact. A general YAML schema can +validate descriptor shape but cannot establish a Provider's dependency coordinate, +constructor, or forwarded mandatory arguments. When the user explicitly states that +no Provider artifact or contract source is available, treat that as evidence; check +the target project metadata for an existing dependency, but do not search unrelated +caches or examples. + +The triage must establish the implementation class or alias, dependency coordinate +or package, constructor/factory contract, and every mandatory descriptor argument. +If any element remains unverifiable: + +- preserve all working dependency, source, and configuration files; +- stop the dependent integration immediately, before the normal Build Workflow; +- do not generate a descriptor, adapter, wrapper, placeholder dependency, or tests; +- report the exact sources checked, each missing contract element, and the minimum + JAR, package, source, test, or documentation needed to continue. + +List a source under `checked` only when its content was actually inspected during +the triage. Do not claim that a bundled schema, reference, cache, or documentation +was checked merely because `SKILL.md` names it. + +Return that blocked report for a Provider-only request. Continue other work only +when the user explicitly requested an independent change that does not depend on the +blocked Provider. If the complete Provider contract is verified, proceed with the +normal workflow using that evidence. + +## Definition Strategy + +| Situation | Recommendation | +|---|---| +| New Workflow Agent or rewired event graph | Offer YAML plus Python/Java implementation files | +| Existing YAML application | Preserve and extend YAML | +| Existing programmatic Agent | Preserve its current API unless conversion is requested | +| `ReActAgent`, unsupported YAML surface, or explicit code-only request | Direct Python or Java API | + +For a new application, derive the available API choices from the confirmed target +version. Present YAML, direct Python API, and direct Java API when all three are +supported; otherwise omit unsupported surfaces. A recommendation explains the +tradeoff; it is not permission to select the API for the user. + +YAML does not declare an agent `type`. Do not add `type: workflow` or +`type: react`; `type` selects an implementation language where the schema allows it. + +YAML does not choose the business implementation language either. For a new YAML +application, ask the user to choose Python or Java before generating files. For an +existing application, detect and preserve the language from build metadata, source +files, function references, and explicit YAML `type` fields. Do not default to +Python merely because omitted YAML `type` fields currently resolve to Python. + +Workflow Agents already include built-in chat, tool-call, and context-retrieval +Actions. A model reasoning/tool loop can therefore remain YAML-defined. Choose +`ReActAgent` only when the user explicitly wants that programmatic abstraction, the +existing application already uses it, or a required ReAct-specific surface is not +available through YAML. + +## Scaffolding Boundary + +Complete framework-owned wiring, but do not invent business behavior. Before +implementing each custom Action, Tool, domain client, data transformation, Prompt, +or runtime Skill, classify these contract elements from only explicit user +requirements, existing code, and existing tests: + +| Contract element | `supplied` means | +|---|---| +| Input | Required fields and their meaning are defined | +| Output | Result fields or emitted Events are defined | +| Transformation | Input-to-output or message construction is defined | +| Side effects | External calls, state changes, and authentication boundary are defined | +| Errors | Required failure and fallback behavior is defined | + +Anything not established by those sources is `unresolved`; a high-level capability +name is not a supplied contract. When behavior depends on an unresolved element, +generate only an importable or compilable signature skeleton with focused TODOs and +an explicit `NotImplementedError` or `UnsupportedOperationException`. Preserve +user-provided names, parameters, types, and descriptions. When those are absent, +derive a stable capability name and use the narrowest framework-compatible +signature; for a function Tool or domain client with no typed contract, use one +opaque string request and string result. + +An unresolved skeleton must not emit business Events, transform payloads, compose +Prompt or chat messages, call a backend, return business data, or provide fallback +behavior. Tests may check import/compilation, signature and YAML reference +resolution, and explicit failure, but must not assert business behavior that was +not supplied. + +Do not author domain rules, REST endpoints, diagnostic procedures, prompts, data +models, Tool results, runtime Skill instructions, or test doubles from a high-level +application idea. Do not propose a standard REST API, MCP server, or mock backend as +the implementation of a custom Tool. Do not ask the user to lock a business input +identity schema, deployment platform, service API, authentication design, or log/ +metric backend merely to scaffold the application. Generate the neutral skeleton +and leave those choices as TODOs for the user. Built-in Flink Agents Actions are +framework behavior and need no generated implementation. + +## Interaction Discipline + +This section is the sole authority for interaction capability detection, fallback +selection, retry behavior, and host-mode handling. Platform adapters only describe +how to encode a question for a tool that this section selected; other references +must point here instead of restating this policy. + +Process the unresolved decisions from Preflight as sequential gates. Ask only the +current gate, wait for the answer, update the decision record, and continue. Never +send one proposed baseline that bundles versions, API, implementation language, +runtime version, Resource implementations, business backends, and mock behavior. + +Only when at least one unresolved closed gate remains, identify the current host +from explicit system and tool context. Use this table to select the sole candidate +tool and adapter for the gate: + +| Host | Candidate structured tool | Adapter | +|---|---|---| +| Codex | `request_user_input` | [codex.md](references/platforms/codex.md) | +| Claude Code | `AskUserQuestion` | [claude-code.md](references/platforms/claude-code.md) | +| Gemini CLI | `ask_user` communication tool | [gemini-cli.md](references/platforms/gemini-cli.md) | +| Qoder | Explicitly exposed structured single-select tool, if any | [qoder.md](references/platforms/qoder.md) | +| Unknown or unsupported | Explicitly exposed structured single-select tool, if any | [generic.md](references/platforms/generic.md) | + +Treat the current tool contract as the only capability signal. When the table's +candidate is exposed and callable, read its adapter to encode one question. An +adapter must not perform capability discovery or select a fallback. If the candidate +is absent, returns an availability error, or cannot represent the complete valid +option set without altering or obscuring it, immediately read +[generic.md](references/platforms/generic.md) and use its numbered fallback. Do not +retry the tool, ask the user to change modes, require a slash command or keyboard +shortcut, perform a post-interview handoff, add host metadata, or install host +configuration in the generated application. + +Never use an open-ended text question when the valid options are already known. +When a gate has a recommendation, place it first and label it `(Recommended)`, but +do not preselect or continue without the user's answer. The YAML +implementation-language gate intentionally has no recommendation: present Python +and Java as equal peer options with parallel descriptions and no `(Recommended)` +label. + +Use this order: + +1. If unresolved, ask for the Flink Agents version and wait. Then offer only + compatible Flink versions and wait for the Flink choice. Do not mention + Python/JDK versions, an Agent API, a model provider, or business architecture in + these version questions. +2. If unresolved, ask the user to choose among the APIs supported by the confirmed + Flink Agents version. Offer YAML only when its exact contract is available. Wait. +3. Only when YAML is selected, ask whether custom Actions, function Tools, and the + Flink entry point use Python or Java. Resource implementations are selected + independently at their own gates. Direct Python or Java API already determines + the application-code choice. Do not recommend, preselect, or imply a preference + for either YAML implementation language. Only now resolve a compatible Python or + JDK version. +4. Inventory the Resources required by the user's stated design. Resolve real + descriptor-backed framework integrations one at a time and wait before moving to + the next integration. Assign a deterministic internal name, ask for its + implementation class or documented alias, and stop the interview for that + Resource. Immediately run Provider Contract Triage after the implementation + choice; it verifies the target-version constructor and descriptor. If it passes, + generate every mandatory configuration key as `TODO_REQUIRED_` for the + user to fill. Do not ask about another Resource or load general references before + the triage result. + For chat-model connections/setups, embedding-model connections/setups, and vector + stores, do not filter candidates by the Agent API or application-code language. + Present all verified Python and Java implementations, label each candidate's + implementation language when needed for disambiguation, and generate the matching + bridge declaration and runtime dependencies after selection. + Do not ask for model identifiers, endpoints, credential values/mechanisms, + provider options, Skill source paths/URLs/packages, or other Resource arguments. + Custom Actions, function Tools, domain clients, Prompt content, runtime Skill + instructions, business input schemas, and backend platforms are not integration + gates: scaffold them without a business interview. Do not ask the user to name a + single Resource or repeat a generated reference. Ask about naming only when + multiple Resources need semantic disambiguation, an existing external reference + constrains the name, or the user requested a naming convention. +5. As soon as the confirmed design first requires a Python runtime, pause before + environment creation or dependency installation. Unless the existing project + already declares its environment unambiguously, inspect compatible local Python + executables/environments and ask whether to reuse one of them or create a + project-local `.venv`. Wait for the choice and use the selected interpreter for + every install, import, test, and local run. This conditional gate fires + immediately after the choice that introduces Python; do not delay it until the + end of the Resource interview. +6. After every framework decision is confirmed, design the Action graph and generate + the project. Do not add a host-specific handoff. + +Do not infer OpenAI, Ollama, any model name, an environment-variable credential, +Skill distribution, MCP, a vector store, or a domain-service protocol. After a +framework implementation is selected, do not run a second configuration interview. +Generate a declaration or builder scaffold that names every verified mandatory +argument and leaves its value explicit for the user. For YAML string fields, use a +clear placeholder such as `TODO_REQUIRED_API_KEY`; for direct APIs, generate a +compilable factory skeleton that lists the required arguments and fails explicitly +until they are filled. If the user already supplied a value or explicitly requested +plaintext in local YAML, use that instruction; keep supplied secrets out of tracked +files and output. Never claim `${ENV_VAR}` is interpolated unless the target loader +or provider actually implements it. + +The conditional Python-environment gate is dependency management, not Resource +configuration. It still runs when a selected Resource introduces Python, but it asks +only which concrete compatible Python environment to use, never provider values. + +## Build Workflow + +1. Run Preflight, including Provider Contract Triage for every concrete external + Provider already named by the request or project. If a Provider is blocked, issue + its report and stop before reading any bundled reference. +2. Read [application patterns](references/application-patterns.md), then ask only the + ordered unresolved gates. Do not repeat decisions already confirmed by the user + or project. +3. When versions are unresolved, complete the version gates in + [local development](references/local-development.md): Flink Agents first, then a + compatible Flink version. Do not generate files or combine later decisions into + these questions. +4. Complete the API gate with only APIs supported by the confirmed target version. + For YAML, read [YAML patterns](references/yaml-patterns.md), select the exact + schema through [yaml-contracts.yaml](assets/yaml-contracts.yaml), and reject + generation when no exact contract is available. +5. If YAML was selected, complete the implementation-language gate. Then read + [Python patterns](references/python-patterns.md) or + [Java patterns](references/java-patterns.md) for the application code. When a + bridge-supported Resource uses the other language, also read that language's + cross-language Resource section; selecting that Resource is already explicit + confirmation. +6. Inventory each Resource and assign stable names automatically. Ask only which + documented implementation alias/class to use for actual framework integrations. + For bridge-supported types, build this choice from both Python and Java + implementations instead of the application-code language alone. Inspect the + selected implementation through Provider Contract Triage before loading more + references or moving to another integration. When it passes, add its integration + and bridge dependencies and scaffold all mandatory configuration keys without + asking for their values. + Classify every custom Tool/Action/Prompt/runtime-Skill business contract before + generating its skeleton; do not ask the user to choose a domain platform or + complete an unresolved input contract. Generate and record all cross-references + without asking the user to repeat internal identifiers. Never select a provider, + model, endpoint, credential source, business backend, or integration from the + application domain alone. +7. When runtime Skills are requested, preserve an explicit source in an existing + application. For a new application, generate a minimal runtime `SKILL.md` business + scaffold plus a source-configuration TODO that lists the valid `paths`, `urls`, + Python `package`, and Java `classpath` forms. Do not ask for loading paths or + distribution, and never inspect, copy, or offer to reuse Skills installed in the + coding-agent host, such as a local `flink-diag`; coding-agent Skills and Flink + runtime Skills are different artifacts. Read + [YAML patterns](references/yaml-patterns.md#runtime-skills). +8. Draw only framework Action graph edges established by built-in contracts or + supplied business contracts. Where an emitted Event, payload transformation, + branch, output, or error contract is unresolved, generate the Action signature + and explicit failure but do not claim or implement that edge. Do not turn those + TODOs into more gates. +9. Use only documented built-in Actions. Generate a resolvable, correctly typed + signature skeleton for every other Action and reference it as + `:`; leave its business body for the user unless they + explicitly requested implementation. +10. Resolve concrete API calls and dependency coordinates from the target-version + artifacts before generating the complete executable project. For Java, generate + a Maven project with + `flink-agents-api`, `flink-agents-plan`, `flink-agents-runtime`, and only the + integrations actually used; declare all Flink Agents and Flink dependencies as + `provided`. Whenever the design requires Python, generate source and dependency + files, resolve the Python environment choice when not already declared, and + install the resolved Flink Agents, PyFlink, and integration dependencies into the + selected existing environment or project-local `.venv`. Read + [local development](references/local-development.md). Preserve versions already + selected by the target project; never guess one. +11. Connect the Agent to a Flink DataStream or Table through the public factory backed + by `RemoteExecutionEnvironment`. Local validation submits that same remote-style + job to a MiniCluster. Never use a local Agents environment, a no-argument factory, + `from_list`/`to_list`, or their Java equivalents. +12. Run the checks in [verification](references/verification.md) before claiming + the application is valid or runnable. + +## Required Output + +- Complete framework files or edits, including every custom function signature and + a remote-style Flink job entry point. +- A runnable Maven project for Java and, whenever Python is required, pinned Python + dependency input plus the user-selected existing environment or populated + project-local `.venv`. +- User-confirmed Flink Agents and Flink versions. Never silently choose the bundled + recommended versions for a new project. +- A user-confirmed API choice from the surfaces supported by the selected Flink + Agents version. +- A user-confirmed Python or Java application-code language for every new YAML + application, reflected consistently in project layout, custom Action/Tool function + references, and the Flink entry point. Do not use it to filter bridge-supported + Resource implementations. +- For applications using runtime Skills, a user-fillable Skill business scaffold + and source-configuration TODO. Preserve existing source configuration, but do not + ask a new-project user to choose distribution or reuse coding-agent host Skills. +- Resource declarations built from user-selected implementation aliases/classes, + including independent Resource implementation language and bridge wiring where + supported, with every verified mandatory provider key present and marked for user + input. + Keep model, endpoint, credential, and optional provider values unresolved instead + of interviewing for them or choosing defaults. +- Deterministic Resource names and references generated by the coding agent. Do not + require the user to name ordinary Connection, Setup, Prompt, Skill container, + VectorStore, or MCP Resource identifiers when there is no ambiguity. +- When the user chooses plaintext credentials for local testing, a loader-compatible + local YAML that is excluded from version control and actually used by the local + run command. Do not replace this explicit choice with programmatic registration. +- Explicit user-fillable business skeletons for custom Actions, Tools, prompts, + runtime Skills, domain clients, secrets, endpoints, and external data, backed by + the supplied/unresolved contract classification. Keep framework wiring and + signatures concrete instead of replacing them with fabricated business + implementation. +- A final consolidated `User must provide` list for unresolved business input + fields, platform clients, authentication, queries, response mapping, and domain + behavior. These items must not block project scaffolding or become Agent workflow + decisions unless the user explicitly asks for their implementation. +- Exact commands that match the target repository's build tooling; no guessed + package, Flink, provider, model, or plugin versions. +- Framework snippets whose imports and API calls resolve in the target version. + Business skeletons must import or compile, fail explicitly when invoked, and be + labeled user-fillable rather than runnable behavior. +- For YAML, the selected contract key and exact schema path used for validation. +- Evidence separated into schema, load/compile, tests, and runtime. +- A clear statement for every check that was not run or requires an external service. + +## Quick Reference + +| Task | Read | +|---|---| +| Present closed choices in the current coding agent | [Interaction Discipline](#interaction-discipline), then its selected platform adapter | +| Select Agent/API shape; design Resources and event graph | [application-patterns.md](references/application-patterns.md) | +| Author or review YAML; resolve names and functions | [yaml-patterns.md](references/yaml-patterns.md) | +| Scaffold runtime Skill business and source TODOs | [runtime Skills](references/application-patterns.md#scaffold-runtime-skills) | +| Scaffold Python Actions, Tools, types, or runner | [python-patterns.md](references/python-patterns.md) | +| Scaffold Java Actions, Tools, resources, or runner | [java-patterns.md](references/java-patterns.md) | +| Select versions, generate dependencies, and submit to MiniCluster | [local-development.md](references/local-development.md) | +| Validate schema, references, imports, compilation, and execution claims | [verification.md](references/verification.md) | +| Select and validate YAML without a source checkout | [YAML contract manifest](assets/yaml-contracts.yaml) | diff --git a/dev/agent-skills/flink-agents-dev/assets/yaml-contracts.yaml b/dev/agent-skills/flink-agents-dev/assets/yaml-contracts.yaml new file mode 100644 index 000000000..be77127ea --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/assets/yaml-contracts.yaml @@ -0,0 +1,22 @@ +contracts: + "0.3.0": + schema: yaml-schemas/release-0.3.0.json + source: + repository: apache/flink-agents + ref: release-0.3.0 + path: docs/yaml-schema.json + blob_sha: 2b21d71db8e7ea93f9a1e98dc4ca11af9298f39f + + main: + schema: yaml-schema.json + source: + repository: apache/flink-agents + ref: main + path: docs/yaml-schema.json + blob_sha: 183cc7ac800cf97d030b75b166ea8146368747d5 + +versions_without_yaml_api: + - "0.2.1" + - "0.2.0" + - "0.1.1" + - "0.1.0" diff --git a/dev/agent-skills/flink-agents-dev/assets/yaml-schema.json b/dev/agent-skills/flink-agents-dev/assets/yaml-schema.json new file mode 100644 index 000000000..183cc7ac8 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/assets/yaml-schema.json @@ -0,0 +1,533 @@ +{ + "$defs": { + "ActionSpec": { + "additionalProperties": false, + "description": "An action references a user function and its trigger conditions.\n\n``function`` is written as ``:`` \u2014 the\ncolon separates the Python module (or Java class FQN) from the\nattribute path inside it.\n\n``trigger_conditions`` carries one or more strings. Each is either an\nevent-type name (bare identifier) or a future condition-expression\nform \u2014 the runtime classifies the string when it loads the plan.\n\nAction signatures are fixed (``(Event, RunnerContext)``), so there is\nno ``parameter_types`` knob \u2014 Python doesn't need it, and the Java\naction signature is determined by the action contract.", + "properties": { + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config" + }, + "function": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Function" + }, + "name": { + "title": "Name", + "type": "string" + }, + "trigger_conditions": { + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Trigger Conditions", + "type": "array" + }, + "type": { + "anyOf": [ + { + "enum": [ + "python", + "java" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + } + }, + "required": [ + "name", + "trigger_conditions" + ], + "title": "ActionSpec", + "type": "object" + }, + "AgentSpec": { + "additionalProperties": false, + "description": "One agent inside a YAML file's ``agents:`` list.\n\nHolds the agent's own resources and actions. Resources/actions declared\nat the file level (siblings of ``agents:``) are merged in by the loader.", + "properties": { + "actions": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/ActionSpec" + }, + { + "type": "string" + } + ] + }, + "title": "Actions", + "type": "array" + }, + "chat_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Connections", + "type": "array" + }, + "chat_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Setups", + "type": "array" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "embedding_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Connections", + "type": "array" + }, + "embedding_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Setups", + "type": "array" + }, + "mcp_servers": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Mcp Servers", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/PromptSpec" + }, + "title": "Prompts", + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/SkillsSpec" + }, + "title": "Skills", + "type": "array" + }, + "tools": { + "items": { + "$ref": "#/$defs/ToolSpec" + }, + "title": "Tools", + "type": "array" + }, + "vector_stores": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Vector Stores", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "AgentSpec", + "type": "object" + }, + "DescriptorSpec": { + "additionalProperties": true, + "description": "Schema for any ResourceDescriptor-backed resource.\n\nRequired: ``name`` and ``clazz``. ``type`` selects the implementation\nlanguage (``\"python\"`` or ``\"java\"``; ``None`` means Python). All\nremaining fields are forwarded verbatim to ``ResourceDescriptor`` as\nkwargs (or as the Java wrapper's kwargs when ``type: java``); the\nforwarding and language-aware wrapping is done by ``loader._build_descriptor``.", + "properties": { + "clazz": { + "title": "Clazz", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "anyOf": [ + { + "enum": [ + "python", + "java" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + } + }, + "required": [ + "name", + "clazz" + ], + "title": "DescriptorSpec", + "type": "object" + }, + "InjectedArg": { + "description": "Declarative source binding for a framework-injected tool parameter.", + "properties": { + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "source": { + "$ref": "#/$defs/ToolParameterSource", + "default": "sensory_memory" + } + }, + "title": "InjectedArg", + "type": "object" + }, + "MessageRole": { + "description": "Role of a message in a chat conversation.", + "enum": [ + "system", + "user", + "assistant", + "tool" + ], + "title": "MessageRole", + "type": "string" + }, + "PackageSkillSpec": { + "additionalProperties": false, + "description": "A single ``package`` skill source entry: a Python package name plus a\nresource path relative to that package's root.", + "properties": { + "package": { + "title": "Package", + "type": "string" + }, + "resource": { + "title": "Resource", + "type": "string" + } + }, + "required": [ + "package", + "resource" + ], + "title": "PackageSkillSpec", + "type": "object" + }, + "PromptMessage": { + "additionalProperties": false, + "description": "One message in a multi-turn prompt template.", + "properties": { + "content": { + "title": "Content", + "type": "string" + }, + "role": { + "$ref": "#/$defs/MessageRole", + "default": "user" + } + }, + "required": [ + "content" + ], + "title": "PromptMessage", + "type": "object" + }, + "PromptSpec": { + "additionalProperties": false, + "description": "Declarative prompt: either a single ``text`` template or a list of\nrole-tagged ``messages``. Exactly one of the two fields must be set.", + "properties": { + "messages": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + }, + "name": { + "title": "Name", + "type": "string" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "name" + ], + "title": "PromptSpec", + "type": "object" + }, + "SkillsSpec": { + "additionalProperties": false, + "description": "Declarative Skills resource: one or more skill sources grouped by scheme.\n\nEach list below maps to a skill source scheme:\n\n- ``paths`` \u2014 ``local`` scheme: directories or ``.zip`` files\n- ``urls`` \u2014 ``url`` scheme: ``http(s)`` URLs pointing to a ``.zip``\n- ``classpath`` \u2014 ``classpath`` scheme (Java-only at runtime): resource\n paths on the Java classpath\n- ``package`` \u2014 ``package`` scheme (Python-only at runtime): resources\n inside installed Python packages, given as ``{package, resource}`` pairs\n\nAt least one of the four must be non-empty. ``classpath`` is exposed on\nPython for YAML schema parity with Java \u2014 it deserializes successfully\nbut ``SkillManager`` on Python will fail at load time because Python does\nnot register a ``classpath`` handler.", + "properties": { + "classpath": { + "items": { + "type": "string" + }, + "title": "Classpath", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "package": { + "items": { + "$ref": "#/$defs/PackageSkillSpec" + }, + "title": "Package", + "type": "array" + }, + "paths": { + "items": { + "type": "string" + }, + "title": "Paths", + "type": "array" + }, + "urls": { + "items": { + "type": "string" + }, + "title": "Urls", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "SkillsSpec", + "type": "object" + }, + "ToolParameterSource": { + "description": "Source for a framework-injected tool parameter.", + "enum": [ + "config", + "sensory_memory", + "short_term_memory" + ], + "title": "ToolParameterSource", + "type": "string" + }, + "ToolSpec": { + "additionalProperties": false, + "description": "Points ``function:`` at a callable tool.\n\n``function`` is written as ``:`` \u2014 the\ncolon separates the Python module (or Java class FQN) from the\nattribute path inside it. For Python, the right side may be a\nnested ``Class.method``.\n\n``parameter_types`` is required when ``type: java`` and is forbidden\notherwise (Python tools are reflected from the callable signature).\nThe list contains one string per declared parameter of the Java\nmethod, in declaration order \u2014 the loader uses it to disambiguate\noverloaded methods on the Java class. Each string is one of:\n\n- A Java primitive name: one of ``boolean``, ``byte``, ``short``,\n ``int``, ``long``, ``float``, ``double``, ``char``.\n- A fully-qualified Java reference type (including boxed\n primitives), e.g. ``java.lang.Double``, ``java.lang.String``,\n ``java.util.List``.\n\nGeneric type arguments are not part of the JVM method descriptor\nand must not be included (``java.util.List``, not\n``java.util.List``).", + "properties": { + "function": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Function" + }, + "injected_args": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/InjectedArg" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Injected Args", + "type": "object" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parameter_types": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parameter Types" + }, + "type": { + "anyOf": [ + { + "enum": [ + "python", + "java" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + } + }, + "required": [ + "name" + ], + "title": "ToolSpec", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Top-level YAML document.\n\nAgents are declared under ``agents:``. The block is optional, so a\nfile may carry only shared infrastructure (chat-model connections,\nvector stores, ...) \u2014 useful for splitting a topology file from an\ninfrastructure file that can be swapped per environment. Resources\nand actions declared at the same level as ``agents:`` are shared:\nresources are registered on the environment; actions can be\nreferenced from any agent by name string.", + "properties": { + "actions": { + "items": { + "$ref": "#/$defs/ActionSpec" + }, + "title": "Actions", + "type": "array" + }, + "agents": { + "items": { + "$ref": "#/$defs/AgentSpec" + }, + "title": "Agents", + "type": "array" + }, + "chat_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Connections", + "type": "array" + }, + "chat_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Setups", + "type": "array" + }, + "embedding_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Connections", + "type": "array" + }, + "embedding_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Setups", + "type": "array" + }, + "mcp_servers": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Mcp Servers", + "type": "array" + }, + "prompts": { + "items": { + "$ref": "#/$defs/PromptSpec" + }, + "title": "Prompts", + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/SkillsSpec" + }, + "title": "Skills", + "type": "array" + }, + "tools": { + "items": { + "$ref": "#/$defs/ToolSpec" + }, + "title": "Tools", + "type": "array" + }, + "vector_stores": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Vector Stores", + "type": "array" + } + }, + "title": "YamlAgentsDocument", + "type": "object" +} diff --git a/dev/agent-skills/flink-agents-dev/assets/yaml-schemas/release-0.3.0.json b/dev/agent-skills/flink-agents-dev/assets/yaml-schemas/release-0.3.0.json new file mode 100644 index 000000000..2b21d71db --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/assets/yaml-schemas/release-0.3.0.json @@ -0,0 +1,485 @@ +{ + "$defs": { + "ActionSpec": { + "additionalProperties": false, + "description": "An action references a user function and the event types it listens to.\n\n``function`` is written as ``:`` \u2014 the\ncolon separates the Python module (or Java class FQN) from the\nattribute path inside it.\n\nAction signatures are fixed (``(Event, RunnerContext)``), so there is\nno ``parameter_types`` knob \u2014 Python doesn't need it, and the Java\naction signature is determined by the action contract.", + "properties": { + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config" + }, + "function": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Function" + }, + "listen_to": { + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Listen To", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "anyOf": [ + { + "enum": [ + "python", + "java" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + } + }, + "required": [ + "name", + "listen_to" + ], + "title": "ActionSpec", + "type": "object" + }, + "AgentSpec": { + "additionalProperties": false, + "description": "One agent inside a YAML file's ``agents:`` list.\n\nHolds the agent's own resources and actions. Resources/actions declared\nat the file level (siblings of ``agents:``) are merged in by the loader.", + "properties": { + "actions": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/ActionSpec" + }, + { + "type": "string" + } + ] + }, + "title": "Actions", + "type": "array" + }, + "chat_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Connections", + "type": "array" + }, + "chat_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Setups", + "type": "array" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "embedding_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Connections", + "type": "array" + }, + "embedding_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Setups", + "type": "array" + }, + "mcp_servers": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Mcp Servers", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/PromptSpec" + }, + "title": "Prompts", + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/SkillsSpec" + }, + "title": "Skills", + "type": "array" + }, + "tools": { + "items": { + "$ref": "#/$defs/ToolSpec" + }, + "title": "Tools", + "type": "array" + }, + "vector_stores": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Vector Stores", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "AgentSpec", + "type": "object" + }, + "DescriptorSpec": { + "additionalProperties": true, + "description": "Schema for any ResourceDescriptor-backed resource.\n\nRequired: ``name`` and ``clazz``. ``type`` selects the implementation\nlanguage (``\"python\"`` or ``\"java\"``; ``None`` means Python). All\nremaining fields are forwarded verbatim to ``ResourceDescriptor`` as\nkwargs (or as the Java wrapper's kwargs when ``type: java``); the\nforwarding and language-aware wrapping is done by ``loader._build_descriptor``.", + "properties": { + "clazz": { + "title": "Clazz", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "anyOf": [ + { + "enum": [ + "python", + "java" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + } + }, + "required": [ + "name", + "clazz" + ], + "title": "DescriptorSpec", + "type": "object" + }, + "MessageRole": { + "description": "Role of a message in a chat conversation.", + "enum": [ + "system", + "user", + "assistant", + "tool" + ], + "title": "MessageRole", + "type": "string" + }, + "PackageSkillSpec": { + "additionalProperties": false, + "description": "A single ``package`` skill source entry: a Python package name plus a\nresource path relative to that package's root.", + "properties": { + "package": { + "title": "Package", + "type": "string" + }, + "resource": { + "title": "Resource", + "type": "string" + } + }, + "required": [ + "package", + "resource" + ], + "title": "PackageSkillSpec", + "type": "object" + }, + "PromptMessage": { + "additionalProperties": false, + "description": "One message in a multi-turn prompt template.", + "properties": { + "content": { + "title": "Content", + "type": "string" + }, + "role": { + "$ref": "#/$defs/MessageRole", + "default": "user" + } + }, + "required": [ + "content" + ], + "title": "PromptMessage", + "type": "object" + }, + "PromptSpec": { + "additionalProperties": false, + "description": "Declarative prompt: either a single ``text`` template or a list of\nrole-tagged ``messages``. Exactly one of the two fields must be set.", + "properties": { + "messages": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + }, + "name": { + "title": "Name", + "type": "string" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "name" + ], + "title": "PromptSpec", + "type": "object" + }, + "SkillsSpec": { + "additionalProperties": false, + "description": "Declarative Skills resource: one or more skill sources grouped by scheme.\n\nEach list below maps to a skill source scheme:\n\n- ``paths`` \u2014 ``local`` scheme: directories or ``.zip`` files\n- ``urls`` \u2014 ``url`` scheme: ``http(s)`` URLs pointing to a ``.zip``\n- ``classpath`` \u2014 ``classpath`` scheme (Java-only at runtime): resource\n paths on the Java classpath\n- ``package`` \u2014 ``package`` scheme (Python-only at runtime): resources\n inside installed Python packages, given as ``{package, resource}`` pairs\n\nAt least one of the four must be non-empty. ``classpath`` is exposed on\nPython for YAML schema parity with Java \u2014 it deserializes successfully\nbut ``SkillManager`` on Python will fail at load time because Python does\nnot register a ``classpath`` handler.", + "properties": { + "classpath": { + "items": { + "type": "string" + }, + "title": "Classpath", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "package": { + "items": { + "$ref": "#/$defs/PackageSkillSpec" + }, + "title": "Package", + "type": "array" + }, + "paths": { + "items": { + "type": "string" + }, + "title": "Paths", + "type": "array" + }, + "urls": { + "items": { + "type": "string" + }, + "title": "Urls", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "SkillsSpec", + "type": "object" + }, + "ToolSpec": { + "additionalProperties": false, + "description": "Points ``function:`` at a callable tool.\n\n``function`` is written as ``:`` \u2014 the\ncolon separates the Python module (or Java class FQN) from the\nattribute path inside it. For Python, the right side may be a\nnested ``Class.method``.\n\n``parameter_types`` is required when ``type: java`` and is forbidden\notherwise (Python tools are reflected from the callable signature).\nThe list contains one string per declared parameter of the Java\nmethod, in declaration order \u2014 the loader uses it to disambiguate\noverloaded methods on the Java class. Each string is one of:\n\n- A Java primitive name: one of ``boolean``, ``byte``, ``short``,\n ``int``, ``long``, ``float``, ``double``, ``char``.\n- A fully-qualified Java reference type (including boxed\n primitives), e.g. ``java.lang.Double``, ``java.lang.String``,\n ``java.util.List``.\n\nGeneric type arguments are not part of the JVM method descriptor\nand must not be included (``java.util.List``, not\n``java.util.List``).", + "properties": { + "function": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Function" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parameter_types": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parameter Types" + }, + "type": { + "anyOf": [ + { + "enum": [ + "python", + "java" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + } + }, + "required": [ + "name" + ], + "title": "ToolSpec", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Top-level YAML document.\n\nAgents are declared under ``agents:``. The block is optional, so a\nfile may carry only shared infrastructure (chat-model connections,\nvector stores, ...) \u2014 useful for splitting a topology file from an\ninfrastructure file that can be swapped per environment. Resources\nand actions declared at the same level as ``agents:`` are shared:\nresources are registered on the environment; actions can be\nreferenced from any agent by name string.", + "properties": { + "actions": { + "items": { + "$ref": "#/$defs/ActionSpec" + }, + "title": "Actions", + "type": "array" + }, + "agents": { + "items": { + "$ref": "#/$defs/AgentSpec" + }, + "title": "Agents", + "type": "array" + }, + "chat_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Connections", + "type": "array" + }, + "chat_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Chat Model Setups", + "type": "array" + }, + "embedding_model_connections": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Connections", + "type": "array" + }, + "embedding_model_setups": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Embedding Model Setups", + "type": "array" + }, + "mcp_servers": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Mcp Servers", + "type": "array" + }, + "prompts": { + "items": { + "$ref": "#/$defs/PromptSpec" + }, + "title": "Prompts", + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/$defs/SkillsSpec" + }, + "title": "Skills", + "type": "array" + }, + "tools": { + "items": { + "$ref": "#/$defs/ToolSpec" + }, + "title": "Tools", + "type": "array" + }, + "vector_stores": { + "items": { + "$ref": "#/$defs/DescriptorSpec" + }, + "title": "Vector Stores", + "type": "array" + } + }, + "title": "YamlAgentsDocument", + "type": "object" +} diff --git a/dev/agent-skills/flink-agents-dev/references/application-patterns.md b/dev/agent-skills/flink-agents-dev/references/application-patterns.md new file mode 100644 index 000000000..8ab194842 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/application-patterns.md @@ -0,0 +1,458 @@ +# Application Patterns + +## Contents + +- [Portable Baseline](#portable-baseline) +- [Run a Gated Interview](#run-a-gated-interview) +- [Present Closed Choices Portably](#present-closed-choices-portably) +- [Choose the API and Agent Form](#choose-the-api-and-agent-form) +- [Choose the Implementation Language](#choose-the-implementation-language) +- [Resource Inventory](#resource-inventory) +- [Resolve Resources One at a Time](#resolve-resources-one-at-a-time) +- [Scaffold Runtime Skills](#scaffold-runtime-skills) +- [Design the Action Graph](#design-the-action-graph) +- [Built-in Action Boundaries](#built-in-action-boundaries) +- [Separate Framework and Business Code](#separate-framework-and-business-code) +- [Minimal Layouts](#minimal-layouts) + +## Portable Baseline + +Do not require a Flink Agents source checkout. This installed skill contains: + +- `references/application-patterns.md`: Agent selection, Resources, Actions, and graph design; +- `references/yaml-patterns.md`: complete YAML structure and naming rules; +- `references/python-patterns.md`: Python Action, Tool, event, and runner contracts; +- `references/java-patterns.md`: Java function, Resource, classpath, and runner contracts; +- `references/local-development.md`: Maven/venv generation and local Flink execution; +- `references/verification.md`: schema, reference, compile, and runtime checks; +- [`assets/yaml-contracts.yaml`](../assets/yaml-contracts.yaml): target-version + mapping for bundled machine-readable YAML contracts. + +These files provide the portable workflow and the YAML contracts listed in the +manifest. For every target version, inspect its installed Python package, Java +dependency/source JAR, released docs, or source tag for programmatic APIs, Provider +arguments, bridges, and runtime behavior. Prefer the target-version contract and +report a mismatch; never require cloning the repository as a setup step. + +## Run a Gated Interview + +Use the Preflight decision record from `SKILL.md`. An empty workspace provides no +project evidence for language, API, provider, credentials, Skill source, or business +integrations, but the user request may still confirm some of those decisions. Do not +answer remaining uncertainty with a combined recommended baseline. Complete and +wait at each unresolved gate: + +| Gate | Ask now | Do not ask or assume yet | +|---|---|---| +| Versions | Flink Agents, then compatible Flink | API, Python/JDK, Resources, business backend | +| API | Target-version supported surfaces | YAML implementation language, providers, credentials | +| Language/runtime | Python or Java as equal choices only for YAML; then compatible interpreter/JDK | Resource implementations | +| Resources | One descriptor-backed framework implementation; generate its required arguments as TODOs | Another integration or any custom business body | +| Python environment | When Python first becomes required: compatible existing environment or project `.venv` | Dependency installation before the choice | +| Application | Action graph and custom signatures | Unspecified domain logic or test doubles | + +The Preflight pass removes decisions already answered by the user or declared +unambiguously by the existing project. Do not load a host adapter when no unresolved +closed gate remains. A recommended option must be presented inside its own gate and +must not silently answer later gates. + +## Present Closed Choices Portably + +For version, API, YAML implementation language, runtime, and framework Resource +implementation gates, the valid set is known. Follow the authoritative +[Interaction Discipline](../SKILL.md#interaction-discipline) for capability +detection, adapter selection, fallback, retry, and mode behavior. This reference +only defines the option sets and their consequences. Use the single adapter selected +there only when one of these gates remains unresolved. +Platform-specific tool names belong only in `references/platforms/`; they must not +leak into the generated application or become a prerequisite for using the core +skill. + +Keep one decision gate per selector. When that gate has a recommendation, put the +recommended option first and label it, but do not mark it selected or proceed on +timeout/silence. The YAML implementation-language gate has no recommended option. +For a target-version list of Resource aliases/providers, offer every implementation +verified as compatible with the selected API either natively or through a supported +cross-language bridge. Do not filter chat-model, embedding-model, or vector-store +implementations by the application-code language. Include a custom class option only +when the framework supports one. Ask free-form text only for genuinely open values +such as a custom class name, model identifier, endpoint, or user-defined Tool +signature. Generate internal Resource names unless the user requested a naming +convention or multiple Resources cannot be distinguished from confirmed context. + +## Choose the API and Agent Form + +After the version pair is confirmed, derive the available surfaces from the target +version. When all three are supported, ask the user to choose: + +| API choice | Consequence | +|---|---| +| YAML API | Declarative Workflow Agent; ask Python or Java implementation language next | +| Direct Python API | Python implementation and PyFlink entry point | +| Direct Java API | Java implementation and Java Flink entry point | + +YAML is the recommended option for a new Workflow Agent only when the selected +version has an exact YAML contract because the schema can validate the wiring. Omit +it for versions marked without YAML support; do not select any API without the +user's answer. Direct API does not imply `ReActAgent`; both Python and Java can +define Workflow Agents programmatically. + +Use a Workflow Agent when the user needs an explicit event graph, deterministic +stages, custom state transitions, branching, joins, or direct Resource access. + +Workflow Agents can also provide a model reasoning/tool loop using the built-in +chat and tool-call Actions. Add the built-in context-retrieval flow when reasoning +needs retrieved context. Do not switch to `ReActAgent` merely because the +application uses model reasoning, Tools, RAG, MCP Tools, or runtime Skills. + +Use the programmatic `ReActAgent` when the user explicitly requests that +opinionated abstraction, the existing application already uses it, or a required +ReAct-specific surface is unavailable through YAML. The YAML schema has no +`type: react` discriminator. Do not simulate one with an unsupported field. + +Preserve an existing definition style unless the user requests conversion or the +current style blocks the requested capability. + +## Choose the Implementation Language + +YAML defines the Agent graph but does not make custom code language-neutral. Only +after the user selects YAML, ask them to choose one implementation language before +creating files: + +| Choice | Generated contracts | +|---|---| +| Python | Python module references for custom Actions/Tools, a user-selected Python environment, and a PyFlink entry point | +| Java | Java class/method references for custom Actions/Tools, Tool `parameter_types`, Maven, and a Java Flink entry point | + +Present these as two equal peer options. Use parallel factual descriptions, do not +append `(Recommended)` to either label, do not preselect either language, and do not +describe list order as a preference. The user must make the choice even when one +language happens to be installed locally. + +Do not mention or choose a Python/JDK version before this language choice. Do not +choose Python because the coding agent runs in Python or because missing YAML `type` +defaults to Python. Do not choose Java merely because Flink runs on the JVM. The +selected language constrains application source layout, runtime compatibility, and +local tooling, but it does not constrain bridge-supported chat-model, +embedding-model, or vector-store implementations. Resolve the compatible interpreter +or JDK immediately after the application-code language is confirmed. + +The Python environment is a separate conditional gate. It also applies when the +application code is Java but a selected Resource implementation or verification +step requires Python. Detect compatible local Python executables and environments, +then ask whether to reuse a specific detected environment or create a project-local +`.venv`. Do not create an environment or install dependencies before the user +chooses. Preserve an existing project environment when project metadata declares it +unambiguously. + +For an existing application, detect and preserve its language from `pom.xml`, Python +dependency metadata, source layout, function references, and explicit YAML `type` +fields on custom Actions and Tools. If those application-code signals conflict, +surface the conflict and confirm the boundary with the user. A chat-model, +embedding-model, or vector-store implementation in the other language is an +independent bridged Resource choice, not an application-language conflict and not a +reason for another confirmation gate. + +## Resource Inventory + +After the API and language gates, build a name table before writing Actions or YAML. +Include only Resources required by the user's stated design; do not add a model, +Skills, MCP, vector store, or Tool because it appeared in an example: + +| Resource | Typical references | +|---|---| +| `chat_model_connections` | `chat_model_setups[].connection` | +| `chat_model_setups` | `ChatRequestEvent.model`; runner/Agent configuration | +| `prompts` | `chat_model_setups[].prompt` | +| `tools` | `chat_model_setups[].tools`; direct `ToolRequestEvent` | +| `skills` | available Skill sources; individual names in chat model `skills` | +| `embedding_model_connections` | `embedding_model_setups[].connection` | +| `embedding_model_setups` | vector-store `embedding_model` argument | +| `vector_stores` | Action lookup; `ContextRetrievalRequestEvent` | +| `mcp_servers` | dynamically discovered MCP prompts and Tools | + +Treat each name as an API. Keep spelling and case identical at declaration and +every reference. Separate a Skills Resource name from the individual Skill names +discovered from its `SKILL.md` files. + +For a new application, naming is coding-agent work rather than a user decision. +Generate deterministic `snake_case` identifiers and wire every reference to them. +Use these defaults when there is one Resource of that role: + +| Resource | Default name | +|---|---| +| Chat-model connection | `chat_model_connection` | +| Chat-model setup | `chat_model` | +| Embedding-model connection | `embedding_model_connection` | +| Embedding-model setup | `embedding_model` | +| Prompt | `_prompt` | +| Tool | snake_case of the confirmed function or capability | +| Skills Resource | `runtime_skills` | +| Vector store | `vector_store` | +| MCP server | `mcp_server` | + +When there are multiple Resources of one role, derive a meaningful provider or +purpose stem from already confirmed information. Ask the user for naming input only +when that information cannot disambiguate them, an existing external reference +requires an exact name, or the user explicitly requested a naming convention. +Preserve existing names during modifications. Report generated names, but do not +pause for confirmation. + +## Resolve Resources One at a Time + +If the request or existing project already named a concrete external Provider, +Preflight must have completed +[Provider Contract Triage](../SKILL.md#provider-contract-triage) before this +reference was loaded. Do not use this section to continue a blocked integration. + +Do not ask the user to approve a complete Resource stack. Select one Resource from +the inventory, resolve it fully, and wait before moving to the next one. Follow its +dependency edges: configure a connection before the setup that names it, and +configure the Prompt, Tools, or Skills before finalizing a setup that references +them. + +For each Resource, generate its name and references. Ask only which documented +framework implementation/alias to use; inspect that target-version implementation +and generate all mandatory configuration fields as TODOs. Function Tools, Prompt +content, runtime Skill instructions, and Skill source configuration are scaffolds, +not provider-integration interviews: + +| Resource | Generate | Ask for | +|---|---|---| +| Chat/embedding connection | Stable name plus every mandatory provider field marked `TODO_REQUIRED_*` | Documented `clazz`/alias only | +| Chat/embedding setup | Stable name, connection reference, and mandatory setup fields marked `TODO_REQUIRED_*` | Documented `clazz`/alias only | +| Prompt | Purpose-derived name and TODO scaffold | Nothing unless the user already supplied content or explicitly requests prompt implementation | +| Function Tool | Capability-derived name, minimal neutral signature, and failing body | Nothing unless the user already supplied a signature or explicitly requests implementation | +| Skills | `runtime_skills`, minimal Skill metadata, TODO instructions, and source-configuration TODO | Nothing; do not interview for source paths or business instructions | +| Vector store | Stable name, embedding-setup reference, and mandatory backend fields marked `TODO_REQUIRED_*` | Documented implementation only | +| MCP server | Stable name and mandatory transport/authentication fields marked `TODO_REQUIRED_*` | Documented implementation only | + +Choosing a Resource implementation determines which integration artifact and +arguments are valid. For chat-model connections/setups, embedding-model +connections/setups, and vector stores, build the candidate set from both Python and +Java implementations supported by the target version. Label otherwise ambiguous +choices with their implementation language, for example `Ollama (Python)` and +`Ollama (Java)`. Selecting one candidate is sufficient confirmation of the Resource +language; do not ask a separate cross-language question. Read the matching +Provider artifact or Provider-specific authoritative docs as part of Provider +Contract Triage. Run that triage immediately after the user names an implementation, +before asking about another Resource or loading general references. Do not propose +OpenAI, Ollama, a model name, or a provider alias from the application domain. + +Generate the corresponding bridge form instead of rewriting the provider into the +application language. In YAML, set each descriptor's `type` from that Resource's +implementation language. In direct Python, use the documented Java wrapper and +`java_clazz` metadata for a Java implementation. In direct Java, use the documented +Python wrapper and `pythonClazz` metadata for a Python implementation. Add the +selected implementation artifact and bridge runtime requirements to the generated +project. Keep connection and setup implementations compatible with one another. + +Do not ask for values after the implementation is selected. Put every verified +mandatory key in the generated declaration and use an unmistakable placeholder, +for example `model: TODO_REQUIRED_MODEL` or `api_key: TODO_REQUIRED_API_KEY`. +Include concise comments that identify required types or constraints when the +target-version API exposes them. Do not invent optional values. Provider verification +and the blocked-report behavior are owned by +[Provider Contract Triage](../SKILL.md#provider-contract-triage); do not weaken or +repeat that policy here. + +An API-key placeholder is not a secret and may remain in a tracked template. If the +user explicitly supplies a real value or asks to keep plaintext in local YAML, put +the value only in an ignored local file and redact output. The lack of general +`${ENV_VAR}` interpolation does not justify another interview; simply document it +and leave the supported credential field for the user to fill. + +A custom Tool, Action, or domain client is business code. Apply the supplied versus +unresolved contract classification from `SKILL.md` before generating it. When +behavior remains unresolved, derive a name from the user's stated capability and +generate a minimal framework-compatible skeleton using opaque inputs and outputs +that fails before performing business behavior. Do not ask the user to choose +structured identity fields, Flink/VVR/VVP platform variants, service endpoints, +authentication, log/metric APIs, or response schemas merely to finish scaffolding. +Record each unknown as a TODO and consolidate it in the final `User must provide` +list. Do not generate a fake or mock implementation or tests for invented behavior +unless the user explicitly requests and defines it. + +## Scaffold Runtime Skills + +Runtime Skill instructions and deployment are user-owned. A coding-agent Skill +installed in Codex, Claude Code, Qoder, Gemini CLI, or another host is not a Flink +runtime Skill artifact. Never inspect host Skill directories, ask whether to reuse a +local Skill such as `flink-diag`, or copy its content into the generated application. + +Preserve an existing YAML source field or `Skills` factory. For a new application, +do not ask how Skills will be loaded. Generate: + +- a minimal runtime `SKILL.md` with capability-derived metadata and a TODO body; +- a `runtime_skills` Resource/source scaffold that clearly requires user input; +- a final TODO listing the supported source forms and deployment requirements. + +The supported forms are reference information for the user's later edit, not +selection options in the scaffolding interview: + +| Source form | YAML source | Direct API | Deployment contract | +|---|---|---|---| +| Bundle with the application | Python `package`; Java `classpath` | Python `Skills.from_package`; Java `Skills.fromClasspath` | Version the Skills with the Python package/wheel or application JAR and install/deploy that artifact on the runtime | +| TaskManager-local path | `paths` | Python `Skills.from_local_dir`; Java `Skills.fromLocalDir` | Every TaskManager must resolve the same directory or ZIP path with the same contents | +| Versioned HTTP(S) ZIP | `urls` | Python `Skills.from_url`; Java `Skills.fromUrl` | Every TaskManager must reach the URL; the ZIP top level contains Skill subdirectories | + +For a YAML scaffold, use a visibly unresolved schema-shaped template such as: + +```yaml +skills: + - name: runtime_skills + # TODO(required): replace this placeholder with paths, urls, package, or classpath. + paths: [TODO_REQUIRED_SKILL_SOURCE] +``` + +The placeholder keeps the required declaration visible; it is not a selected +distribution mode and is not runnable. For a direct API scaffold, generate a +compilable factory/helper that lists the valid factories and throws +`NotImplementedError` or `UnsupportedOperationException` until the user chooses one. +Do not package, mount, download, or activate the runtime Skill on the user's behalf +unless they later provide the source configuration or explicitly ask for it. + +Once the user fills the source, multiple fields may coexist only when explicitly +intended. The implementation language removes invalid choices (`package` is +Python-only and `classpath` is Java-only), while `paths` and `urls` can bridge +languages. Validate loader order and deployment constraints at that time. +YAML loaders append them in `paths`, `urls`, `classpath`, `package` order. Runtime +registration is last-wins for duplicate Skill frontmatter names, so avoid duplicate +names instead of relying on an implicit override or fallback chain. Prefer immutable, +versioned URLs because the YAML contract has no checksum field. A path that works in +a local MiniCluster proves only single-machine availability, not cluster-wide +TaskManager visibility. + +## Design the Action Graph + +Write the framework graph before code. This is an internal design checklist, not a +business requirements interview. Use explicit user requirements when available; +otherwise generate stable stage/event names and TODO-marked opaque payloads without +asking blocking questions: + +| Item | Record | +|---|---| +| Trigger | A documented built-in alias or generated custom event type | +| Input | Confirmed attributes, or an opaque payload with a TODO for the domain schema | +| State | Only explicitly requested memory; otherwise no invented domain state | +| Resources | Exact generated or preserved Resource names | +| Emission | Framework stage transition; unresolved business fields remain TODOs | +| Failure | Explicit failure for skeleton code; domain recovery remains a TODO | + +Multiple Actions may listen to the same event. Account for fan-out, duplicate +outputs, joins, and correlation state explicitly. Normally emit `OutputEvent` only +after the final response; it bypasses further Action routing and reaches downstream +immediately. + +## Built-in Action Boundaries + +The official docs currently identify these built-in behaviors: + +| Built-in Action | Listens for | Emits | +|---|---|---| +| `chat_model_action` | `ChatRequestEvent`, `ToolResponseEvent` | `ChatResponseEvent` or `ToolRequestEvent` | +| `tool_call_action` | `ToolRequestEvent` | `ToolResponseEvent` | +| `context_retrieval_action` | `ContextRetrievalRequestEvent` | `ContextRetrievalResponseEvent` | + +These Actions are added automatically when an AgentPlan is compiled. Do not invent +YAML declarations or implementation functions for them. Invoke them through their +documented Events and configured Resources. + +The reasoning/tool loop is: + +```text +ChatRequestEvent -> chat_model_action -> ToolRequestEvent +ToolRequestEvent -> tool_call_action -> ToolResponseEvent +ToolResponseEvent -> chat_model_action -> ChatResponseEvent or another ToolRequestEvent +``` + +Start the loop from a custom Action by emitting `ChatRequestEvent`. Handle the final +`ChatResponseEvent` in another custom Action, usually by emitting `OutputEvent`. +Context retrieval can run before or between reasoning stages by emitting +`ContextRetrievalRequestEvent` and handling `ContextRetrievalResponseEvent`. + +Every Action listed in YAML is otherwise a custom Action and needs a concrete, +resolvable function signature. If a purported built-in is not documented for the +target version, treat it as custom until source or tests prove otherwise. + +## Separate Framework and Business Code + +Generate framework-owned code completely: + +- project structure, dependency metadata, YAML sections, names, and references; +- built-in Action wiring and Resource declarations whose arguments the user chose; +- typed custom Action and Tool signatures with the target-version imports; +- runtime Skill directories and valid frontmatter when the user requests them; +- the Flink DataStream/Table entry point and MiniCluster submission path. + +Scaffold business-owned code unless the user explicitly requests its implementation +and provides enough behavior to implement it: + +- custom Action and Tool bodies; +- service clients, REST paths, queries, authentication, and response semantics; +- domain models and transformations not specified by the user; +- prompt policy, diagnosis or decision procedures, and Tool result interpretation; +- runtime Skill instructions and allowed commands; +- simulated services, fake Tool results, and test doubles. + +A function scaffold must import or compile and expose the exact signature referenced +by YAML, but its body should contain a focused TODO and raise +`NotImplementedError` or `UnsupportedOperationException`. A runtime Skill scaffold +contains its confirmed `name` and `description`, followed by a TODO for the user; do +not expand a one-line business goal into a runbook. Do not add tests that pretend +placeholder business behavior is implemented, and do not replace the placeholder +with a mock merely to make a behavior test pass. + +When no custom Tool signature was supplied, use the least committal supported shape, +normally one `String`/`str` request and one `String`/`str` result. Name the function +from the stated capability, document that the request/result contracts are TODOs, +and keep the body explicitly failing. This is a scaffold convention, not a claim +that the eventual business API should use strings. + +## Minimal Layouts + +Python YAML application: + +```text +app/ +├── .venv/ # only when selected; generated locally, never committed +├── requirements.txt +├── .gitignore +├── agent.yaml +├── actions.py +├── tools.py # when the Agent declares local Tools +├── types.py +├── main.py +├── resources/ +│ └── skills//SKILL.md # `paths` distribution only +└── tests/ +``` + +Java YAML application: + +```text +app/ +├── pom.xml +└── src/ + ├── main/java/com/example/agent/{Actions,Types,Main}.java + ├── main/resources/ + │ ├── yaml/agent.yaml + │ └── skills//SKILL.md # `classpath` only + └── test/java/com/example/agent/ +``` + +For Python `package` distribution, generate an installable package and place Skills +under that package's configured package-data path instead of the flat `resources/` +directory. For Java `classpath` distribution, keep Skills under +`src/main/resources` so Maven includes them in the application JAR. For `urls`, do +not copy the ZIP into the application artifact. Apply the user's confirmed choice +consistently in YAML, project metadata, packaging, and verification. + +Adapt to the target repository rather than forcing these layouts. Keep a streaming +file source pointed at input data only; do not point it at a parent directory that +also contains Skill or YAML assets. + +For every new application, generate the actual dependency project and a remote-style +Flink entry point. Follow `local-development.md`; an Agent definition without a +MiniCluster submission path is incomplete. When business functions are still +placeholders, keep deployment-only and behavior verification claims separate. diff --git a/dev/agent-skills/flink-agents-dev/references/java-patterns.md b/dev/agent-skills/flink-agents-dev/references/java-patterns.md new file mode 100644 index 000000000..661508cf8 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/java-patterns.md @@ -0,0 +1,185 @@ +# Java Patterns + +## Contents + +- [Match the Installed API](#match-the-installed-api) +- [Cross-language Descriptor Resources](#cross-language-descriptor-resources) +- [YAML-referenced Implementations](#yaml-referenced-implementations) +- [Function Tools](#function-tools) +- [Java-loaded YAML](#java-loaded-yaml) +- [Programmatic ReActAgent](#programmatic-reactagent) +- [Java Checks](#java-checks) + +## Match the Installed API + +Inspect the target `pom.xml` or Gradle build, Java version, Flink version, Flink Agents +version, and existing examples. Reuse those versions and scopes. Do not copy versions +from a different branch or release. If no dependency metadata exists, use the +supported choices in `local-development.md` and obtain the user's selection before +writing the POM or source files. + +The patterns below are the bundled offline baseline. When target dependencies are +available, inspect the installed Flink Agents JARs, source JARs, and build metadata +for changed signatures or provider integrations. A source checkout is optional. + +## Cross-language Descriptor Resources + +A direct Java Agent may use Python implementations of chat-model +connections/setups, embedding-model connections/setups, and vector stores. Do not +filter Resource provider choices to Java implementations merely because the Agent, +custom Actions, or entry point are Java. + +After the user selects a Python implementation, build the target-version documented +descriptor with the corresponding Java-side Python wrapper and `pythonClazz` set to +the selected Python implementation FQN. Install the matching Python integration +package in the TaskManager Python environment and include the Python bridge runtime. +Do not translate the provider into a Java implementation or ask for a separate +cross-language confirmation. Verify the wrapper map before applying this pattern to +another Resource type. + +## YAML-referenced Implementations + +Java custom Actions use a public static method with the fixed Event and +`RunnerContext` parameters. YAML supplies those parameter types automatically; do +not add `parameter_types` to an Action. When the contract classification in +`SKILL.md` leaves required behavior unresolved, generate compilable skeletons rather +than inventing event transformations: + +```java +public static void processInput(Event event, RunnerContext ctx) throws Exception { + throw new UnsupportedOperationException( + "TODO: define and implement the application-specific Action contract"); +} + +public static void processChatResponse(Event event, RunnerContext ctx) throws Exception { + throw new UnsupportedOperationException( + "TODO: define and implement the application-specific Action contract"); +} +``` + +Implement event construction only after the user supplies the required behavior. +Confirm constructors against the target version and format with the target build. +Reference the methods with `com.example.agent.Actions:processInput` and +`com.example.agent.Actions:processChatResponse`. For an inner class, use `$` on the +left side, for example `com.example.Outer$Actions:processInput`. + +## Function Tools + +Java Tool methods are public static methods. YAML must provide one ordered Java type +per declared parameter so overloaded methods can be resolved: + +```yaml +tools: + - name: lookupOrder + type: java + function: com.example.agent.Tools:lookupOrder + parameter_types: [java.lang.String] +``` + +Use primitive names or fully qualified reference types. Omit generic arguments: +write `java.util.List`, not `java.util.List`. Keep method annotations and +parameter metadata consistent with the target version's Tool documentation. + +Generate a matching method skeleton by default: + +```java +public static String queryLogs(String request) { + throw new UnsupportedOperationException( + "TODO: define the request contract and connect the log backend"); +} +``` + +Do not invent clients, endpoints, request/response fields, or error handling unless +the user explicitly supplied that business contract. If only the capability is +known, do not stop to ask for identity fields, platform variants, service APIs, or +authentication. Use `String request` and `String` result as neutral placeholder +boundary types, keep the body explicitly failing, and list the unresolved contract +after scaffolding. This placeholder is not a recommendation for the final domain +API. + +## Java-loaded YAML + +Missing `type` defaults to Python even in the Java loader. Set `type: java` on every +Java Action, Tool, chat/embedding descriptor, and vector store. Prompts and Skills +use their own schema rather than a language `type`. + +Runtime Skill behavior and source configuration are user-owned. Preserve an existing +`classpath`, `paths`, or `urls` source. For a new application, generate a minimal +`SKILL.md` TODO scaffold and list `Skills.fromClasspath(...)`, +`Skills.fromLocalDir(...)`, and `Skills.fromUrl(...)` in a factory TODO; do not ask +the user to select one and do not package or load a source speculatively. Keep the +factory compilable but explicitly failing until configured. + +Never inspect the coding-agent host's Skill directories or offer to reuse a local +Skill such as `flink-diag`. Those files are business instructions for another host, +not Java classpath assets. See `yaml-patterns.md#runtime-skills` for the fields the +user can fill later. + +Provider aliases and arguments are language-specific. For example, a provider may +use different aliases or camelCase arguments in Java. Copy them from the matching +Java docs or examples; schema validation alone cannot verify forwarded descriptor +arguments. + +That rule refers to the selected Resource implementation language, not the Java +application language. For a Python Resource selected by a Java application, use the +Python alias/class and Python argument contract together with the Java-side wrapper +and `pythonClazz` metadata. + +### MCP Limitation + +In the current source, Java `AgentPlan` rejects MCP servers registered through +`Agent.addResource`, while Java `YamlLoader` maps YAML `mcp_servers` to that path. +Do not generate a Java-loaded YAML application with MCP and describe it as runnable. +Use the documented programmatic `@MCPServer` static method instead, or confirm in the +target source that this restriction no longer exists. Keep the whole Agent in a +supported definition style; do not invent wrapper Agents, loader result accessors, +or YAML-to-MCP adapters. + +When this limitation requires the programmatic form, still generate every custom +Action and Tool signature whose framework contract is confirmed. Unknown provider +coordinates, endpoints, and business behavior remain explicit user-fillable +placeholders rather than fabricated implementation or an architecture checklist. + +## Programmatic ReActAgent + +Do not choose `ReActAgent` merely for a reasoning/tool loop; Workflow Agents already +provide that loop through built-in Actions. When the decision rules do select the +programmatic abstraction, construct it from the user-confirmed chat-model descriptor, +optional Prompt, and optional POJO class or `RowTypeInfo` output schema. This is shape +pseudocode; resolve each value after the Resource interview: + +```java +ReActAgent agent = + new ReActAgent( + configuredChatModelDescriptor, + confirmedPromptOrNull, + confirmedOutputClassOrNull); +``` + +Build `configuredChatModelDescriptor` after the user selects its implementation. +Generate its verified mandatory arguments as TODOs rather than asking for values. +Register every Resource it references under the exact name before `.apply(agent)`; +do not introduce a provider, model, Prompt, or Tool solely to complete the example. + +## Java Checks + +- Validate YAML before compilation. +- Compile the exact module with Maven/Gradle and the configured JDK. +- Resolve every Java function reference to a public static method. +- Check each Tool's `parameter_types` count and order against reflection. +- Check every Action uses the framework's fixed Event/`RunnerContext` contract. +- For a Python chat-model, embedding-model, or vector-store implementation, verify + the Java descriptor wrapper, `pythonClazz`, installed Python integration package, + and Python resource adapter path. Do not reject it because the application code is + Java. +- Resolve every generated framework method and constructor in target source, then + compile it. Label unverified fragments as pseudocode rather than runnable code. +- Run focused unit tests. Only after the user fills a runtime Skill source: inspect + the JAR for configured classpath Resources, or verify `paths`/`urls` deployment + preconditions without treating local access as cluster proof. +- Do not claim a Flink job ran from a successful compile or JAR inspection. +- Follow `local-development.md` to generate the Maven project with the API, plan, + runtime, and required integration artifacts in `provided` scope, then submit the + bounded remote-style Flink job to a local MiniCluster. Use the public + `AgentsExecutionEnvironment.getExecutionEnvironment(env)` factory backed by + `RemoteExecutionEnvironment`; never use a local Agents environment or list APIs. diff --git a/dev/agent-skills/flink-agents-dev/references/local-development.md b/dev/agent-skills/flink-agents-dev/references/local-development.md new file mode 100644 index 000000000..fe7ca9a92 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/local-development.md @@ -0,0 +1,521 @@ +# Project Generation and MiniCluster Validation + +## Contents + +- [Delivery Contract](#delivery-contract) +- [Resolve and Confirm a Compatible Version Set](#resolve-and-confirm-a-compatible-version-set) +- [Generate a Java Maven Project](#generate-a-java-maven-project) +- [Generate a Python Project and Select an Environment](#generate-a-python-project-and-select-an-environment) +- [Handle Local Plaintext Credentials](#handle-local-plaintext-credentials) +- [Scaffold Runtime Skill Configuration](#scaffold-runtime-skill-configuration) +- [Connect the Agent through RemoteExecutionEnvironment](#connect-the-agent-through-remoteexecutionenvironment) +- [Submit to a Local MiniCluster](#submit-to-a-local-minicluster) + +## Delivery Contract + +Do not stop after generating an Agent class or YAML definition. For a new +application, also generate and verify its executable project: + +- Java: a complete Maven project with `pom.xml`, sources, Resources, a Flink job + entry point, and a configured local run command; +- Python: source files, pinned dependency input, `.gitignore`, a user-selected + existing Python environment or project-local `.venv`, installed dependencies, and + a Flink job entry point; +- both: a `StreamExecutionEnvironment`, the public Agents factory backed by + `RemoteExecutionEnvironment`, a keyed DataStream/Table, a sink, and an + `AgentsExecutionEnvironment.execute(...)` call. + +For existing applications, preserve their package manager and layout, but provide +the same runnable path. "Local" changes only the Flink deployment target: the job +is still built through `RemoteExecutionEnvironment` and submitted to a MiniCluster. +Never substitute the removed local Agents APIs. + +## Resolve and Confirm a Compatible Version Set + +For an existing application, resolve versions from its lockfile, Maven metadata, +installed package, or `FLINK_HOME`, preserve them, and report what was detected. For +a new application, do not create dependency files, a virtual environment, or source +files until all sequential gates are complete. Versions are the first gate; do not +choose or mention the implementation language before it. + +Offer choices rather than asking an open-ended version question. The publication +snapshot, mirrored from `tools/install.sh`, is: + +| Component | Installer-supported versions | New-project choices | Recommended choice | +|---|---|---|---| +| Flink Agents | `0.3.0`, `0.2.1`, `0.2.0`, `0.1.1`, `0.1.0` | `0.3.0`, `0.2.1`, `0.1.1` | `0.3.0` | +| Flink | `2.2.1`, `2.1.3`, `2.0.2`, `1.20.5` | `2.2.1`, `2.1.3`, `2.0.2`, `1.20.5` | `2.2.1` | + +For a new project, offer only the highest supported patch in each Flink Agents +minor line. Older patches such as `0.2.0` and `0.1.0` remain valid when an existing +project pins them or the user explicitly requests one, but they add no useful +choice to the default new-project menu. + +Version selection also constrains the later API gate. Use +[yaml-contracts.yaml](../assets/yaml-contracts.yaml) as the offline YAML capability +index: + +- `0.3.0` supports YAML with its bundled release schema; +- `0.2.x` and `0.1.x` do not expose the YAML API, so offer only direct Python and + direct Java after one of those releases is selected; +- a source checkout targeting `main` uses that checkout's + `docs/yaml-schema.json`, falling back to the bundled main schema only when they + describe the same revision; +- an unlisted release or development version requires its exact schema before YAML + can be offered. + +The selected schema is the entire YAML structural contract. Do not maintain +individual compatibility guesses for `listen_to`, `trigger_conditions`, +`injected_args`, or future fields outside that schema. + +Render the Flink Agents row through the adapter selected by the authoritative +[Interaction Discipline](../SKILL.md#interaction-discipline); capability detection +and fallback behavior are defined only there. The complete default option set is: + +```text +Select the Flink Agents version: +1. 0.3.0 (Recommended) +2. 0.2.1 +3. 0.1.1 + +Reply with 1, 2, or 3. +``` + +After that answer, present the compatible Flink versions through a separate native +selector or numbered list. Never put both version decisions into one multi-select +control: they are ordered single-choice gates because the first filters the second. + +Flink Agents `0.1.x` supports Flink `1.20` only in this snapshot. Flink Agents +`0.2.x` and `0.3.x` publish artifacts for all listed Flink minors. Use a +target-version installer, release metadata, or compatibility matrix when available +because it overrides this bundled snapshot. + +Present the recommended choice first, but do not select it because the user is +silent. Ask only for the Flink Agents version and wait. Then show only the Flink +versions compatible with that answer and wait again. These questions must not also +propose YAML/Python/Java APIs, Python/JDK versions, model providers, Resource +configuration, Skill loading, business backends, or mocks. Summarize only the +confirmed Flink Agents/Flink pair before moving to the API gate. + +Do not describe a bundled recommendation as "latest", "preview", or preferable to +an unlisted Flink release unless target-version release metadata was actually +checked and that comparison is needed for the user's request. The installer choices +are a compatibility menu, not permission to make broader release claims. + +After the API and implementation language are confirmed, resolve the language +runtime separately. When the design first requires Python, inspect available +interpreters and environments and keep only versions compatible with the already +selected Flink pair; do not assume Python 3.12. In this snapshot, Python 3.12 +requires Flink Agents `0.3+` and Flink `2.1+`; older combinations require Python +3.10 or 3.11. Then ask whether to reuse one specific compatible environment or +create a project-local `.venv` from a compatible interpreter. This conditional gate +also fires when Java application code selects a Python Resource. For Java, detect or +ask for a compatible JDK only after Java application code is selected. Keep these +values aligned: + +- the exact Flink patch version used for local execution; +- the Flink Agents Java or Python package version; +- every Flink Agents module and integration version; +- the user-confirmed Python or Java application-code language; +- each selected Resource implementation language and bridge dependency; +- the Java and Python versions supported by that Flink Agents release. + +The selected Flink binary may require a newer Java runtime; use the higher +requirement from Flink and Flink Agents. Do not leave guessed versions or unresolved +version placeholders in the generated project. + +Resolve integration dependencies from each Resource's implementation language, not +from the application language. A Java application using a bridge-supported Python +chat model, embedding model, or vector store still needs the corresponding Python +package in every TaskManager environment. A Java implementation selected by a +Python application still needs its Java integration JAR on the runtime classpath. +Generate both sides of that dependency contract and the target-version bridge +runtime; do not hide the foreign-language dependency or replace the Resource. + +## Generate a Java Maven Project + +Generate at least: + +```text +agent-app/ +├── pom.xml +└── src/ + ├── main/java//{Actions,Tools,Main}.java + ├── main/resources/agent.yaml + └── test/java// +``` + +Use the Flink Agents application modules directly. Always add +`flink-agents-api`, `flink-agents-plan`, and `flink-agents-runtime`; add only the +integration artifacts required by the Resources declared in the application. Do +not use a dist artifact or `flink-agents-ide-support` as the application's compile +contract. + +Declare every Flink Agents and Flink dependency with `provided` scope. The target +deployment must supply the matching Flink Agents modules, integrations, and Flink +runtime; `provided` is a packaging contract, not an automatic cluster install. The +generated forked local-run configuration includes these dependencies through +Maven's compile classpath. + +```xml + + RESOLVE_EXACT_FLINK_VERSION + RESOLVE_EXACT_AGENTS_VERSION + RESOLVE_COMPATIBLE_JAVA_RELEASE + RESOLVE_MAIN_CLASS + 3.14.1 + 3.6.3 + + + + + org.apache.flink + flink-agents-api + ${flink-agents.version} + provided + + + org.apache.flink + flink-agents-plan + ${flink-agents.version} + provided + + + org.apache.flink + flink-agents-runtime + ${flink-agents.version} + provided + + + org.apache.flink + flink-streaming-java + ${flink.version} + provided + + + org.apache.flink + flink-clients + ${flink.version} + provided + + + org.apache.flink + flink-table-api-java-bridge + ${flink.version} + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${maven.compiler.release} + + + + org.codehaus.mojo + exec-maven-plugin + ${exec-maven-plugin.version} + + ${java.home}/bin/java + compile + + -classpath + + ${main.class} + + + + + +``` + +For each selected integration, add its target-version artifact with the same +Flink Agents version and `provided` scope. Resolve its actual artifact from +target-version metadata only after the user confirms the Resource implementation; +do not select an integration from an example and do not add every integration +preemptively. The Table bridge is required even for the default DataStream job +because the `AgentsExecutionEnvironment` API and runtime constructor reference +`StreamTableEnvironment`. Add the matching Table planner only for Table API jobs, +and add connector dependencies only for the selected sources and sinks. Declare +those Flink artifacts as `provided` too. + +Generate a complete POM, not just the fragment. The bundled plugin versions above +are a verified baseline; preserve a compatible newer version already selected by +the target project. Run the main class in a forked JVM with compile classpath scope: + +```bash +mvn clean compile exec:exec +``` + +Do not use `exec:java` as the default. Its in-process plugin ClassLoader can make a +Flink MiniCluster fail while deserializing operator factories. `exec:exec` starts a +normal JVM with the generated dependency classpath. + +Keep `compile` in the forked local-run +configuration: Maven's compile classpath includes `provided` dependencies, so the +same POM supports both local smoke tests and cluster-oriented packaging without a +second dependency profile. + +## Generate a Python Project and Select an Environment + +Generate at least: + +```text +agent-app/ +├── .gitignore +├── requirements.txt +├── agent.yaml +├── actions.py +├── tools.py +└── main.py +``` + +Pin the resolved versions in `requirements.txt`: + +```text +flink-agents==RESOLVE_EXACT_AGENTS_VERSION +apache-flink==RESOLVE_EXACT_FLINK_VERSION +``` + +Include any application-specific dependencies used by Actions or Tools. Before +installing them, inspect compatible local Python executables and environments. If +the existing project does not already declare one unambiguously, present a closed +choice that names concrete paths, for example: + +1. `Create .venv with /path/to/python3.11 (Recommended)` +2. `Reuse /path/to/existing/environment/bin/python` + +If several compatible existing environments or base interpreters exist, use the +host adapter's hierarchy so the user selects an exact executable. Do not create the +environment, install dependencies, or silently use the active shell Python before +the answer. + +For a project-local environment, create and populate it with the selected base +interpreter: + +```bash + -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +.venv/bin/python -m pip check +``` + +For an existing environment, do not create `.venv`; use the exact selected +executable for installation and verification: + +```bash + -m pip install -r requirements.txt + -m pip check +``` + +On Windows, use the selected executable path such as `.venv\Scripts\python`. Add +`.venv/` to `.gitignore` only when it is created, and always ignore caches, logs, +and local secrets. Use the selected executable for every later import, test, build, +and local job command. If an existing interpreter rejects installation because it +is externally managed or read-only, report that result and return to the environment +choice instead of bypassing its protection or silently creating a venv. + +The `flink-agents` Python package carries the common and Flink-version-specific +Flink Agents JARs. Constructing `AgentsExecutionEnvironment` registers those JARs +with the Flink pipeline, so a Python application must not copy another Flink Agents +dist JAR into the project. The generated Action modules must still be importable +from the working directory or installed application package. + +## Handle Local Plaintext Credentials + +Do not ask the user to choose a credential mechanism while scaffolding a Resource. +Generate required credential keys with `TODO_REQUIRED_*` values. Plaintext YAML is +still a valid local-testing mechanism when the user explicitly requests it or +provides a value; do not reject that instruction or replace it with Java/Python +`addResource` wiring. Put a real value only in a clearly local file such as +`config/agent.local.yaml`, add its exact path to `.gitignore`, and make the local +Java or Python entry point load that file. Keep a tracked secret-free example only +when it helps the user reproduce the layout. + +If the project is a Git worktree, verify the protection before running: + +```bash +git check-ignore -v config/agent.local.yaml +git status --short +``` + +The first command must identify the intended ignore rule, and the secret-bearing +file must not appear as untracked or staged in the second command. If the workspace +does not use Git, state that repository-level protection could not be verified. + +Do not ask for the value. When the user supplies one without prompting, write it +only to the ignored local file. Do not place it in a shell command, generated test +fixture, tracked example, Maven configuration, dependency file, console output, +diff excerpt, or final report. Warn once that the file contains plaintext and is for +local testing; then continue with the verification that its filled configuration +permits. + +## Scaffold Runtime Skill Configuration + +For an existing application, preserve and validate its configured runtime Skill +source. For a new application, do not ask the user to choose distribution and do +not inspect or reuse coding-agent host Skills. Generate a runtime `SKILL.md` TODO +shell and an unresolved source declaration/factory TODO. The following table tells +the user what they can fill later: + +| Choice | Generated project requirement | +|---|---| +| Bundle with Java application | Use YAML `classpath` or `Skills.fromClasspath`; place Skill directories under `src/main/resources/` and verify the built JAR contains them | +| Bundle with Python application | Use YAML `package` or `Skills.from_package`; generate an installable Python package, include the Skill tree as package data, build or install it into the selected Python environment, and verify the resource is readable from that installed package | +| TaskManager-local path | Use YAML `paths` or the language's local-dir factory; accept directories or ZIPs and document the path that every TaskManager must mount or provision | +| Versioned HTTP(S) ZIP | Use YAML `urls` or the language's URL factory; require a ZIP whose top level contains Skill directories and document TaskManager network access | + +Do not copy a local `flink-diag` or any Skill found under Codex, Claude Code, Qoder, +Gemini CLI, or another coding-agent installation. The generated runtime Skill shell +belongs to the user's application and contains only capability-derived metadata plus +a TODO body. + +Do not package, mount, or download the runtime Skill until the user fills the source. +If the user later chooses bundled Python Skills, use a package layout such as: + +```text +agent-app/ +├── pyproject.toml +└── src/app/ + ├── __init__.py + └── resources/skills//SKILL.md +``` + +Then configure the selected build backend to include every Skill Markdown file, script, +and reference as package data. Install the application package with the same +selected Python executable used to run the job, for example with +` -m pip install -e .`, then verify the configured `package` and +`resource` pair through the installed package-resource API. Deploy that package to +every Python worker in the target cluster; installation in the selected local +environment proves only local availability. Do not point `package` at an uninstalled +source directory. + +Do not treat implementation language as the distribution decision: Java and Python +may both use `paths` or `urls`. Use those portable schemes for an intentional +cross-language source. If multiple YAML source fields are explicitly combined, +preserve loader order `paths`, `urls`, `classpath`, `package` and reject duplicate +Skill names rather than depending on last-wins replacement. + +A local MiniCluster shares one machine and can validate artifact contents, ZIP +shape, and local resolution. It cannot prove that a distributed cluster mounts the +same path or allows every TaskManager to reach a URL. Report those as deployment +requirements unless they were verified in the target cluster environment. Prefer +immutable, versioned URLs; the current YAML contract has no checksum field. + +## Connect the Agent through RemoteExecutionEnvironment + +Both Python and Java must create a Flink `StreamExecutionEnvironment` first, then +pass it to the public `AgentsExecutionEnvironment` factory. In the supported API, +that factory creates `RemoteExecutionEnvironment`; use the public factory instead +of importing the runtime implementation directly. + +Never call the Python factory without `env`, and never use a local Agents +environment, `from_list`/`to_list`, or Java list equivalents. Local validation means +the remote-style Flink job is submitted to the MiniCluster selected by the local +`StreamExecutionEnvironment`. + +Use a bounded source so the validation command terminates. Apply a YAML Agent by its +declared name; apply a programmatic Agent by instance. + +Python shape: + +```python +from pathlib import Path + +from pyflink.common import Types +from pyflink.datastream import StreamExecutionEnvironment + +from flink_agents.api.execution_environment import AgentsExecutionEnvironment + + +env = StreamExecutionEnvironment.get_execution_environment() +env.set_parallelism(1) +agents_env = AgentsExecutionEnvironment.get_execution_environment(env) +agents_env.load_yaml(Path(__file__).with_name("agent.yaml")) + +input_stream = env.from_collection( + ["local smoke test"], type_info=Types.STRING() +) +output_stream = ( + agents_env.from_datastream(input_stream, key_selector=lambda value: value) + .apply("agent_name") + .to_datastream() +) +output_stream.print() +agents_env.execute("Flink Agent MiniCluster Validation") +``` + +Java shape: + +```java +StreamExecutionEnvironment env = + StreamExecutionEnvironment.getExecutionEnvironment(); +env.setParallelism(1); +AgentsExecutionEnvironment agentsEnv = + AgentsExecutionEnvironment.getExecutionEnvironment(env); +agentsEnv.loadYaml(Paths.get("src/main/resources/agent.yaml")); + +DataStream input = env.fromElements("local smoke test"); +DataStream output = + agentsEnv + .fromDataStream( + input, (KeySelector) value -> value) + .apply("agent_name") + .toDataStream(); +output.print(); +agentsEnv.execute("Flink Agent MiniCluster Validation"); +``` + +Adapt types, key selection, Agent name, and paths to the generated application. +Use a key that is stable for all events belonging to one Agent invocation. A +continuous file or message source may be added separately, but it must not replace +the terminating smoke-test path. + +The non-empty examples above are behavior smoke tests and require implemented +business functions. When custom Action or Tool bodies are intentionally still +scaffolds, do not invoke them with fabricated behavior. A deployment-only check may +use a typed empty bounded source, such as Python +`env.from_collection([], type_info=Types.STRING())` or Java +`env.fromCollection(Collections.emptyList(), Types.STRING)`, while preserving the +Agent operator and sink. Report that this validates job construction, submission, +and deployment only; it does not validate Action, Tool, model, or output behavior. + +## Submit to a Local MiniCluster + +Python, using the environment selected earlier: + +```bash + -c "import flink_agents; import pyflink" + main.py +``` + +Java: + +```bash +mvn dependency:tree +mvn clean compile exec:exec +``` + +Running these commands directly uses Flink's local MiniCluster while retaining the +same `RemoteExecutionEnvironment` integration used for cluster submission. Before a +behavior smoke test, configure only the user-confirmed model/backend and credential +mechanism. Do not generate or select a test double to fill an unspecified business +implementation. If the user has not implemented the business bodies or configured +the external Resource, run deployment-only validation and state what blocks behavior +or integration validation. + +Report evidence at the correct level: + +- deployment validation: the bounded job was submitted to the MiniCluster and + finished; an empty source is acceptable when business functions are scaffolds; +- behavior validation: non-empty input invoked the implemented business functions, + produced the expected sink record, and finished; +- integration validation: the external model, MCP server, vector store, or service + was actually reached. + +A compile, YAML load, or successful deployment-only job is not proof of business or +external integration behavior. diff --git a/dev/agent-skills/flink-agents-dev/references/platforms/claude-code.md b/dev/agent-skills/flink-agents-dev/references/platforms/claude-code.md new file mode 100644 index 000000000..020566f69 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/platforms/claude-code.md @@ -0,0 +1,25 @@ +# Claude Code Interaction Adapter + +Use this adapter only when explicit host context identifies Claude Code. + +Capability detection and fallback are owned by +[Interaction Discipline](../../SKILL.md#interaction-discipline). Read this adapter +only after it selects an exposed `AskUserQuestion` contract. Ask exactly one +question for the current gate, use its option list, set `multiSelect` to `false`, and +wait for the answer before continuing. When a gate has a recommendation, put it +first and label it `(Recommended)`. The YAML +implementation-language gate has no recommendation; present Python and Java with +parallel descriptions and no recommendation label. + +If the live schema cannot fit all valid choices in one question, preserve every +choice with a meaningful hierarchy or short paged selectors. Return control to +[Interaction Discipline](../../SKILL.md#interaction-discipline) when splitting +would obscure the choices. + +If `AskUserQuestion` reports an availability error, return control to +[Interaction Discipline](../../SKILL.md#interaction-discipline). Do not add it to +allowed tools or modify Claude Code settings. The generated Flink Agents project +must not contain Claude Code configuration. + +Follow the argument schema exposed by the running Claude Code version. Do not copy +a stale parameter shape from this reference when the live tool contract differs. diff --git a/dev/agent-skills/flink-agents-dev/references/platforms/codex.md b/dev/agent-skills/flink-agents-dev/references/platforms/codex.md new file mode 100644 index 000000000..793782751 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/platforms/codex.md @@ -0,0 +1,79 @@ +# Codex Interaction Adapter + +Use this adapter only when explicit host context identifies Codex. + +## Scope + +Capability detection, fallback, retry, and mode behavior are owned exclusively by +[Interaction Discipline](../../SKILL.md#interaction-discipline). Read this adapter +only after that policy has selected Codex and an exposed `request_user_input` +contract. If the call reports an availability error, return control to that policy; +do not select a fallback in this file. + +## Native Single Select + +Call `request_user_input` with exactly one question for the current gate and wait +for its result before doing more work. Omit `autoResolutionMs` because every gate +requires an explicit answer. Use stable `snake_case` IDs, a short header, concise +labels, and one-sentence descriptions. When a gate has a recommendation, put it +first and suffix its label with `(Recommended)`. The YAML implementation-language +gate has no recommendation: give Python and Java parallel descriptions and suffix +neither label. + +For example, when the selected Flink Agents version supports all three surfaces, +the API gate maps to: + +```text +request_user_input( + questions=[ + { + "header": "Agent API", + "id": "agent_api", + "question": "Select the API for this Flink Agents application.", + "options": [ + { + "label": "YAML API (Recommended)", + "description": "Use schema-validated declarative workflow wiring." + }, + { + "label": "Direct Python API", + "description": "Build the application programmatically in Python." + }, + { + "label": "Direct Java API", + "description": "Build the application programmatically in Java." + } + ] + } + ] +) +``` + +Follow the argument schema exposed by the running Codex version if it differs from +this snapshot. + +## Option Limits + +The bundled Codex contract accepts two or three options per question. Preserve all +choices with a short hierarchy rather than dropping options or immediately using a +text list. + +The default new-project Flink Agents menu fits in one native selector: + +1. `0.3.0 (Recommended)` +2. `0.2.1` +3. `0.1.1` + +Do not offer older patches from those minor lines unless an existing project pins +one or the user explicitly requests it. For the four bundled Flink versions, offer +`2.2.1 (Recommended)`, `2.1.3`, and `Older Flink`, then resolve the last choice to +`2.0.2` or `1.20.5`. Apply compatibility filtering before building the hierarchy. + +If compatibility filtering leaves one valid value but the live tool requires at +least two options, offer `Use (Recommended)` and `Change previous choice`. +The second option returns to the preceding gate; it is not another version. + +For another option set, use meaningful release/provider families when they are +unambiguous. Otherwise use short paged native selectors. If hierarchy or paging +would alter or obscure the choices, return control to +[Interaction Discipline](../../SKILL.md#interaction-discipline). diff --git a/dev/agent-skills/flink-agents-dev/references/platforms/gemini-cli.md b/dev/agent-skills/flink-agents-dev/references/platforms/gemini-cli.md new file mode 100644 index 000000000..0d2348023 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/platforms/gemini-cli.md @@ -0,0 +1,23 @@ +# Gemini CLI Interaction Adapter + +Use this adapter only when explicit host context identifies Gemini CLI. + +Capability detection and fallback are owned by +[Interaction Discipline](../../SKILL.md#interaction-discipline). Read this adapter +only after it selects an exposed `ask_user` communication-tool contract. Call it +with one single-select question for the current gate and wait for the answer. +Provide concise option descriptions, put a recommended option first only when the +gate has one, and keep multi-select disabled. The YAML implementation-language gate +has no recommendation; present Python and Java with parallel descriptions and no +recommendation label. + +If the live schema cannot fit all valid choices in one question, preserve every +choice with a meaningful hierarchy or short paged selectors. Return control to +[Interaction Discipline](../../SKILL.md#interaction-discipline) when splitting +would obscure the choices. + +If `ask_user` reports an availability error, return control to +[Interaction Discipline](../../SKILL.md#interaction-discipline). Do not confuse the +communication tool with an approval-policy decision of the same name or change +Gemini settings. Follow the live tool schema when its arguments differ from this +bundled snapshot. diff --git a/dev/agent-skills/flink-agents-dev/references/platforms/generic.md b/dev/agent-skills/flink-agents-dev/references/platforms/generic.md new file mode 100644 index 000000000..fb184ef91 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/platforms/generic.md @@ -0,0 +1,35 @@ +# Generic Interaction Adapter + +Use this adapter only when selected by the authoritative +[Interaction Discipline](../../SKILL.md#interaction-discipline). That policy +supplies either an exposed generic structured-question contract or the decision to +render the numbered fallback; do not redetect capabilities or change that selection +here. + +## Closed Choices + +When [Interaction Discipline](../../SKILL.md#interaction-discipline) supplied a +generic structured-question contract, use its live argument schema for one +single-select gate and wait. When it selected the numbered fallback, render all +valid choices as a numbered list without trying a tool first. For example, when the +selected Flink Agents version supports all three API surfaces: + +```text +Select the API: +1. YAML API (Recommended) +2. Direct Python API +3. Direct Java API + +Reply with 1, 2, or 3. +``` + +Put one option on each line. When a gate has a recommended option, keep it first and +label it, but do not preselect it. The YAML implementation-language gate has no +recommendation: list Python and Java with parallel descriptions and no +`(Recommended)` label. Stop after the current question and wait for an explicit +answer. Do not combine gates, continue on silence, or replace a known enumeration +with an open-ended question. + +For a non-interactive or headless run, emit the same numbered question and stop. +The caller must resume or rerun with the selected value; never choose a default to +keep automation moving. diff --git a/dev/agent-skills/flink-agents-dev/references/platforms/qoder.md b/dev/agent-skills/flink-agents-dev/references/platforms/qoder.md new file mode 100644 index 000000000..d13c81abb --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/platforms/qoder.md @@ -0,0 +1,18 @@ +# Qoder Interaction Adapter + +Use this adapter only when explicit host context identifies Qoder. + +The bundled publication snapshot does not assume a stable Qoder structured-question +tool name or argument contract. Capability detection and fallback are owned by +[Interaction Discipline](../../SKILL.md#interaction-discipline). Read this adapter +only after it selects an explicitly exposed structured single-select tool. Call that +tool according to the live schema for one gate at a time and wait for the answer. + +For the YAML implementation-language gate, present Python and Java as equal peer +options with parallel descriptions. Do not mark either language as recommended or +preselect one. + +If the selected tool reports an availability error, return control to +[Interaction Discipline](../../SKILL.md#interaction-discipline). Do not guess that +a tool from Codex, Claude Code, or Gemini CLI exists in Qoder, and do not add +Qoder-specific metadata to the generated Flink Agents project. diff --git a/dev/agent-skills/flink-agents-dev/references/python-patterns.md b/dev/agent-skills/flink-agents-dev/references/python-patterns.md new file mode 100644 index 000000000..5164d276a --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/python-patterns.md @@ -0,0 +1,183 @@ +# Python Patterns + +## Contents + +- [Match the Installed API](#match-the-installed-api) +- [Cross-language Descriptor Resources](#cross-language-descriptor-resources) +- [YAML-referenced Implementations](#yaml-referenced-implementations) +- [Function Tools](#function-tools) +- [Resource Access and Events](#resource-access-and-events) +- [Runtime Skill Packaging](#runtime-skill-packaging) +- [Programmatic ReActAgent](#programmatic-reactagent) +- [Python Checks](#python-checks) + +## Match the Installed API + +Inspect the target application's dependency version, imports, tests, and existing +Agents code. Confirm uncertain constructors and provider parameters in the matching +docs or source. Do not pin guessed Flink, Flink Agents, provider, or model versions. +If the target has no dependency metadata, use the supported choices in +`local-development.md` and obtain the user's selection before writing dependency or +source files. + +The patterns below are the bundled offline baseline. When a target environment is +available, inspect the installed `flink_agents` package and its metadata for changed +signatures or provider integrations. A source checkout is optional. + +## Cross-language Descriptor Resources + +A direct Python Agent may use Java implementations of chat-model connections/setups, +embedding-model connections/setups, and vector stores. Do not filter Resource +provider choices to Python implementations merely because the Agent, custom Actions, +or entry point are Python. + +After the user selects a Java implementation, build the target-version documented +descriptor with the corresponding Python-side Java wrapper and `java_clazz` set to +the selected Java implementation FQN. Add the matching Java integration artifact to +the job/runtime classpath as well as the Python bridge dependencies. Do not translate +the provider into a Python implementation or ask for a separate cross-language +confirmation. This bridge does not establish support for arbitrary Resource types; +verify the wrapper map in the target version. + +## YAML-referenced Implementations + +Custom Actions use the fixed `(Event, RunnerContext) -> None` contract. When the +contract classification in `SKILL.md` leaves any required behavior unresolved, +generate importable skeletons rather than inferring event transformations or domain +policy: + +```python +from flink_agents.api.events.event import Event +from flink_agents.api.runner_context import RunnerContext + + +def process_input(event: Event, ctx: RunnerContext) -> None: + """TODO: Define and implement the application-specific Action contract.""" + raise NotImplementedError( + "Implement the application-specific input Action" + ) + + +def process_chat_response(event: Event, ctx: RunnerContext) -> None: + """TODO: Define and implement the application-specific Action contract.""" + raise NotImplementedError( + "Implement the application-specific response Action" + ) +``` + +Implement an event transformation only when the user explicitly requests it and its +input/output behavior is known. Then adapt each event constructor only after reading +its signature in the target version. For custom event types, emit +`Event(type="...", attributes={...})` and use the same type string in YAML +under the selected schema's Action trigger field. + +Treat each constructor keyword and property as a versioned contract. Import or +compile the generated module against the target environment before describing it as +runnable; otherwise label the example as pseudocode and identify the unresolved +symbol. + +YAML references these as: + +```yaml +function: app.actions:process_input +function: app.actions:process_chat_response +``` + +The module must be importable from the runner's environment. Static methods use +`module:Class.method`. Generate the module and callable before claiming the YAML +loads. + +## Function Tools + +Use typed parameters and a useful docstring so the framework can derive the Tool +schema. Keep framework-owned values out of the model schema with documented injected +arguments rather than hidden globals. If the user stated a capability but did not +supply its business contract, do not stop to interview for domain identity fields, +platform variants, endpoints, or authentication. Generate a neutral skeleton: + +```python +def query_logs(request: str) -> str: + """TODO: Define the diagnostic request and result contracts.""" + raise NotImplementedError("Connect the user-selected log backend") +``` + +`NotImplementedError` is the default when the user has not provided the business +integration. Do not invent an HTTP client, endpoint, query, authentication scheme, +response shape, or fallback merely to make the Tool look complete. `request: str` +and `str` are placeholder boundary types, not a recommended domain API. Keep all +Flink Agents wiring around the signature complete and list the unresolved contract +for the user after scaffolding. + +## Resource Access and Events + +Use `ctx.get_resource(name, ResourceType.)` with an exact declared name. For +vector retrieval, prefer the documented built-in flow when it fits: + +1. Send `ContextRetrievalRequestEvent` using the target version's constructor. +2. Handle `ContextRetrievalResponseEvent` in a custom Action. +3. Read its documents and send the next event. + +For direct queries, construct `VectorStoreQuery` and call the retrieved vector +store. Do not mix direct and event-driven retrieval accidentally. + +## Runtime Skill Packaging + +Runtime Skill behavior and source configuration are user-owned. Preserve an existing +`package`, `paths`, or `urls` source. For a new application, generate a minimal +`SKILL.md` TODO scaffold and list `Skills.from_package(...)`, +`Skills.from_local_dir(...)`, and `Skills.from_url(...)` in a factory TODO; do not +ask the user to select one and do not package or load a source speculatively. + +The factory helper must remain importable and fail explicitly until configured. +Never inspect Python environment, Codex, Claude Code, Qoder, or other host Skill +directories to find reusable business content. A host `flink-diag` Skill is not a +Flink runtime Skill dependency. See `yaml-patterns.md#runtime-skills` for the fields +the user can fill later. + +## Programmatic ReActAgent + +Do not choose `ReActAgent` merely for a reasoning/tool loop; Workflow Agents already +provide that loop through built-in Actions. When the decision rules do select the +programmatic abstraction, construct it from the user-confirmed chat-model descriptor, +optional Prompt, and optional Pydantic or `RowTypeInfo` output schema. This is shape +pseudocode; resolve each value after the Resource interview: + +```python +from flink_agents.api.agents.react_agent import ReActAgent + +agent = ReActAgent( + chat_model=configured_chat_model_descriptor, + prompt=confirmed_prompt_or_none, + output_schema=confirmed_output_schema_or_none, +) +``` + +Build `configured_chat_model_descriptor` only after the user selects its +implementation. Generate its verified mandatory arguments as TODOs rather than +asking for values. Register every Resource it references under the exact name before +`.apply(agent)`; do not introduce a provider, model, Prompt, or Tool solely to +complete the example. + +## Python Checks + +- Parse and build YAML with the Flink Agents loader. +- Import every left-side module in `function` references and resolve each qualname. +- Inspect or test each Action signature and emitted event constructor. +- Run focused pytest tests with the target repository's environment. +- Set the repository-required `PYTHONPATH` before Python-facing or cross-language + tests when working in the Flink Agents source checkout. +- Exercise provider integrations only when their services and credentials are + available; label skipped integration checks. +- For a Java chat-model, embedding-model, or vector-store implementation, verify the + Python descriptor wrapper, `java_clazz`, selected Java integration JAR, and Java + resource adapter path. Do not reject it because the application code is Python. +- Only after the user fills a runtime Skill source: for bundled Skills, inspect the + built wheel or installed package and load the configured package-data resource; + for `paths` or `urls`, verify the corresponding deployment preconditions without + treating local access as cluster proof. +- Follow `local-development.md` to ask whether to reuse a compatible existing + Python environment or create `.venv`, install dependencies only after that choice, + and submit the bounded remote-style Flink job to a local MiniCluster. Always pass a + `StreamExecutionEnvironment` to `AgentsExecutionEnvironment.get_execution_environment`; + never use the no-argument factory, a local Agents environment, `from_list`, or + `to_list`. diff --git a/dev/agent-skills/flink-agents-dev/references/verification.md b/dev/agent-skills/flink-agents-dev/references/verification.md new file mode 100644 index 000000000..c5f209152 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/verification.md @@ -0,0 +1,356 @@ +# Verification + +## Contents + +- [1. Version and Source](#1-version-and-source) +- [2. YAML Schema](#2-yaml-schema) +- [3. Static Reference Graph](#3-static-reference-graph) +- [4. Action and Event Graph](#4-action-and-event-graph) +- [5. Language Checks](#5-language-checks) +- [6. Resource-specific Checks](#6-resource-specific-checks) +- [7. Runtime Evidence](#7-runtime-evidence) +- [Final Report Template](#final-report-template) + +Verify in layers. A later layer does not replace an earlier one. + +## 1. Version and Source + +- Identify the target Flink Agents, Flink, language, and provider versions from + dependency metadata or installed packages. +- For a new project, record the user's explicit Flink Agents and Flink choices. A + bundled recommended version without user confirmation is not valid evidence. +- Record the user's explicit API choice from the surfaces supported by the confirmed + version pair. A combined recommended baseline is not evidence for any of these + independent decisions. +- Confirm Preflight resolved explicit user choices and existing project evidence + before constructing the unresolved gate list. A host adapter is valid only when + that list contains a closed decision. +- Confirm each unresolved gate followed the authoritative + [Interaction Discipline](../SKILL.md#interaction-discipline); platform references + must not redefine its capability, fallback, retry, or mode policy. Reject + open-ended version/API/language questions when the valid options were known, and + reject a preselected recommended value without a user response. +- For a new YAML project, record the user's explicit Python or Java implementation + choice after the YAML selection. An omitted YAML `type` default is not evidence + of user intent. Record a Python/JDK choice only after the implementation language + is known. +- When runtime Skills are requested for a new project, confirm that source and + business implementation remain explicit TODOs rather than interview gates. For an + existing configured source, preserve and validate its deployment topology. +- Require one resolved version set across the Maven Flink Agents modules and + integrations or Python package, the selected Flink patch release, and the local + Java/Python runtime. +- For YAML, resolve an exact target-version schema through + [yaml-contracts.yaml](../assets/yaml-contracts.yaml) or target-version artifacts. + Reject YAML for versions marked unsupported, and stop when no exact schema exists. + Do not validate a release against the bundled main schema as a compatibility + assumption. +- Record any API assumption that could not be confirmed. Do not generate a guessed + version merely to make a dependency file look complete. + +## 2. YAML Schema + +Validate every generated or modified YAML file against the exact selected schema. +Record the contract key and schema path. If `check-jsonschema` is already available: + +```bash +check-jsonschema --schemafile path/to/agent.yaml +``` + +Resolve a bundled `` relative to the installed `SKILL.md`. Do not +assume the current working directory is a Flink Agents source checkout, and do not +substitute a different version's schema when the selected one is unavailable. + +Otherwise use another real JSON Schema validator that accepts YAML. A YAML parser, +formatter, or linter only proves syntax and is not a schema substitute. + +When working in the Flink Agents source checkout, the Python typed loader is a useful +additional check: + +```bash +cd python +python - path/to/agent.yaml <<'PY' +from pathlib import Path +import sys + +from flink_agents.api.yaml.loader import build_agents + +agents, shared_resources, shared_actions = build_agents(Path(sys.argv[1])) +print("agents:", sorted(agents)) +print("shared actions:", sorted(shared_actions)) +PY +``` + +Use the repository's configured environment (`uv run --no-sync`, activated virtual +environment, or equivalent) instead of assuming system Python has the dependencies. + +## 3. Static Reference Graph + +Check every name edge explicitly: + +- chat setup -> connection; +- chat setup -> Prompt; +- chat setup -> local or MCP Tool names; +- chat setup -> individual runtime Skill names; +- embedding setup -> embedding connection; +- vector store -> embedding setup; +- Action implementation -> Resource names passed to context lookups; +- emitted `ChatRequestEvent.model` -> chat setup; +- YAML shared Action string -> top-level Action; +- Python/Java `function` reference -> real callable/method. + +Check uniqueness within each file. Across files loaded into the same execution +environment, multiple YAML loads accumulate and duplicate Agent or shared Resource +names fail. Shared Actions and their string references are file-scoped rather than +registered globally. + +## 4. Action and Event Graph + +For each YAML Action, classify it: + +| Classification | Required evidence | +|---|---| +| Documented built-in behavior | Target-version docs/source identify its Event contract | +| Custom Python Action | Importable callable with `(Event, RunnerContext) -> None` | +| Custom Java Action | Public static method with Event and `RunnerContext` parameters | + +Trace every path from `input` to `OutputEvent`. Check fan-out, correlation state, +custom event type strings, error paths, and whether any input can terminate without +an output. Verify event constructor arguments against the installed API rather than +memory. + +For scaffolding, distinguish intended edges from implemented edges. Verify that each +custom component classified input, output, transformation, side effects, and errors +as supplied or unresolved from explicit requirements, existing code, and tests. +Every custom callable must exist with the right signature, list each unresolved +contract element, and fail explicitly before performing behavior that depends on +one. Do not claim the path emits an event until that output contract is supplied and +implemented. Reject invented domain logic, payload transformations, message +construction, service calls, prompts, runtime Skill instructions, business results, +and tests that assert behavior the user did not specify. + +## 5. Language Checks + +Python: + +- Require pinned `flink-agents` and `apache-flink` dependency input. +- Require a recorded user choice between a compatible existing Python environment + and a project-local `.venv`, unless existing project metadata already made the + environment unambiguous. +- Run the exact selected Python executable for every install, import, test, and job; + do not require `.venv` when the user selected an existing environment. +- Run `pip check` with that executable. +- Import every function module from the same working directory/import path as the + runner. +- Resolve nested qualnames. +- Run focused pytest tests for Actions, Tools, serialization, and graph branches. +- Check the runner's input/output types, key selector, and packaging of assets. +- When `package` Skills are selected, require an installable application package, + include the full Skill tree as package data, install it into the selected Python + environment, and resolve the configured resource from the installed package. +- Require `StreamExecutionEnvironment.get_execution_environment()` followed by + `AgentsExecutionEnvironment.get_execution_environment(env)`, which is backed by + `RemoteExecutionEnvironment`. Reject the no-argument Agents factory, + `from_list`/`to_list`, and local Agents environment APIs. + +Java: + +- Require a complete Maven project with `flink-agents-api`, `flink-agents-plan`, + `flink-agents-runtime`, and only the integration artifacts actually used. +- Require explicit Flink streaming, client, and Table bridge dependencies. Require + the matching Table planner when the job uses Table API, and selected connector + dependencies for non-built-in sources or sinks. +- Require `provided` scope on every Flink Agents and Flink dependency. Do not use a + dist artifact or `flink-agents-ide-support` as an application dependency. +- Run `mvn dependency:tree` and inspect the resolved versions before compilation. +- Run the remote-style main class against a local MiniCluster in a forked JVM, for + example through the generated `exec:exec` configuration; do not treat `exec:java` + as equivalent evidence. +- Compile with the target Maven/Gradle command and configured JDK. +- Require `type: java` for Java YAML implementations. +- Match each Tool's `parameter_types` to the method parameters in order. +- Inspect the built JAR for YAML and, when `classpath` Skills are selected, the + configured Skill resource tree. +- Require `AgentsExecutionEnvironment.getExecutionEnvironment(env)`, which is backed + by `RemoteExecutionEnvironment`. Reject local Agents environment and list APIs. + +Cross-language: + +- Treat the selected API/application-code language and each Resource implementation + language as independent dimensions. +- For chat-model connections/setups, embedding-model connections/setups, and vector + stores, selecting a provider implementation is sufficient confirmation of its + language. Do not require an additional application-wide cross-language question. +- Confirm the Resource type supports the bridge in the target-version API, not only + in the matching YAML docs; direct Python and direct Java APIs support these bridges + too. +- Confirm the generated descriptor uses the correct wrapper plus `java_clazz` or + `pythonClazz` metadata, or the equivalent target-version factory. +- Confirm the selected language's integration artifact and bridge runtime are + available to every TaskManager. +- Run the repository's cross-language tests or an equivalent focused test. +- Do not assume MCP, Skill source schemes, or arbitrary providers bridge languages. + +## 6. Resource-specific Checks + +All Resources: + +- Each Resource was requested by the user or required by a confirmed reference; + reject Resources inferred only from the application domain or a sample. +- Each descriptor-backed Resource uses the user-selected implementation class or + documented alias. Its declaration contains every mandatory target-version + argument as a typed/commented `TODO_REQUIRED_*` placeholder; reject additional + configuration interviews or invented optional values. +- If the implementation class, dependency coordinate, constructor, descriptor, or + mandatory arguments cannot be verified, confirm that dependent integration work + stopped without changing working configuration. The report must name sources + checked, the missing contract, and the minimum external artifact needed to + continue; a speculative wrapper or `TODO_VERIFY_*` runnable claim is invalid. +- New-project Resource names are deterministic and all references use them + consistently. Do not require user confirmation for an unambiguous generated name; + require naming input only for collisions, external contracts, or an explicit + naming convention. Existing names remain unchanged unless renaming was requested. +- Model names, endpoints, authentication, and optional provider settings are either + explicitly supplied by the user or left as clear TODOs. Reject assumed provider + values and reject scaffolding that blocks on collecting these values. +- No tracked file contains a supplied secret. Plaintext in a user-selected local + YAML is valid when the file is ignored, the local entry point actually loads it, + and output/reporting redacts the value. In a Git worktree, run `git check-ignore` + and inspect `git status --short` before runtime validation. +- Do not treat `${ENV_VAR}` in YAML as secret injection unless the target + loader/provider explicitly resolves it. Do not require programmatic Resource + registration when the user selected a literal value in ignored local YAML. +- Resource references are resolved in dependency order, and the required + integration artifact is selected only after the implementation is known. + +Custom Tools, Actions, and domain clients: + +- Preserve supplied names/signatures/types/descriptions. Verify the supplied versus + unresolved classification for input, output, transformation, side effects, and + errors. When details are absent, capability-derived neutral signatures must + compile or import and fail explicitly before emitting Events, transforming data, + composing messages, calling a backend, or returning business data; missing + business contracts are TODOs, not failed gates. +- Permit tests for imports, compilation, signatures, references, and explicit + failure. Reject tests that assert invented business transformations or results. +- Reject guessed Flink REST endpoints, service protocols, domain transformations, + and mock/test implementations unless the user explicitly requested and specified + them. +- Reject scaffolding workflows that require the user to choose business identity + fields, Flink/VVR/VVP platforms, log/metric interfaces, business authentication, + or response schemas before files are generated. + +Runtime Skills: + +- Each Skill directory contains valid `SKILL.md` frontmatter. +- A user-fillable Skill scaffold contains only capability-derived metadata and a + clear TODO; + it must not contain invented domain instructions or commands. +- For a new project, the source is an explicit TODO and no coding-agent host Skill + was inspected, copied, or offered for reuse. Do not require a path, URL, package, + classpath, or distribution answer before scaffolding. +- Once the user fills it, the YAML field or direct `Skills` factory matches the source: + bundled Python uses `package`/`from_package`, bundled Java uses + `classpath`/`fromClasspath`, TaskManager-managed files use + `paths`/`from_local_dir`/`fromLocalDir`, and remote ZIP distribution uses + `urls`/`from_url`/`fromUrl`. +- A `package` source is Python-only and its resource is present in the installed + package/wheel on every Python worker; a `classpath` source is Java-only and its + resource is present in the application JAR or runtime classpath. +- For a filled source, every `paths` directory or ZIP is provisioned at a resolvable path on every + TaskManager. A local MiniCluster check is not cluster-wide path evidence. +- For a filled source, every `urls` value is HTTP(S), points to a ZIP with Skill directories at its top + level, and is reachable from every TaskManager. Prefer immutable, versioned URLs; + do not claim cluster connectivity from a client-side download. +- Cross-language Skill sources use `paths` or `urls` and are tested across the + confirmed bridge. +- If fields are intentionally combined, account for loader order `paths`, `urls`, + `classpath`, `package`; reject duplicate Skill names rather than relying on + last-wins replacement. +- Chat model `skills` entries match individual Skill names, not the Skills Resource + name. +- `allowed_commands` contains only commands actually required by the enabled Skills. + +MCP: + +- Endpoint/auth configuration matches the language-specific docs. +- Discovered prompt/Tool names exist on the target server. +- Static checks do not claim MCP connectivity. + +Vector stores and embedding models: + +- Provider and language are supported together. +- Connection/setup/vector-store name references resolve. +- Collection/index, dimensions, and backend arguments match the provider. +- Tests distinguish schema/load checks from live backend queries. + +File sources: + +- Point streaming sources at input files/directories only. +- Exclude YAML, Skill, and other Resource assets from recursive input enumeration. + +## 7. Runtime Evidence + +Provide exact commands and name the user-confirmed configuration/credential +mechanism without reproducing a secret. Mention environment variables only when the +user selected them and the generated wiring resolves them. Label evidence precisely: + +- `schema valid`: a JSON Schema validator completed successfully; +- `loads`: the Flink Agents loader built the definitions; +- `imports`: Python references resolved; +- `compiles`: Java build completed; +- `tests pass`: name the command and result; +- `MiniCluster deployment passed`: the remote-style bounded Flink job was submitted + locally and finished; an empty typed source proves deployment only; +- `behavior smoke passed`: non-empty input invoked implemented business functions, + produced the expected sink output, and the job terminated successfully; +- `integration verified`: the external model/MCP/vector service was actually reached. + +Never upgrade `compiles` to MiniCluster deployment, deployment-only evidence to +behavior evidence, or `schema valid` to `provider configuration works`. State +skipped checks and the exact missing implementation, service, credential, +dependency, or environment. + +## Final Report Template + +```text +Changed: +- + +Selected versions: +- Flink Agents ; Flink + +Selected API: +- + +YAML contract, when selected: +- + +Implementation language: +- + +Python environment, when required: +- + +Selected Resource implementations: +- + +Blocked integrations: +- + +Runtime Skill source: +- +- + +User must provide: +- +- +- +- + +Verified: +- : + +Not run: +- : +``` diff --git a/dev/agent-skills/flink-agents-dev/references/yaml-patterns.md b/dev/agent-skills/flink-agents-dev/references/yaml-patterns.md new file mode 100644 index 000000000..1dd369433 --- /dev/null +++ b/dev/agent-skills/flink-agents-dev/references/yaml-patterns.md @@ -0,0 +1,320 @@ +# YAML Patterns + +## Contents + +- [Contract First](#contract-first) +- [Versioned Workflow Shape](#versioned-workflow-shape) +- [Section Rules](#section-rules) +- [Language Selection](#language-selection) +- [Name-resolution Pass](#name-resolution-pass) + +## Contract First + +Resolve the target Flink Agents version before generating YAML. Select its complete +schema through the [YAML contract manifest](../assets/yaml-contracts.yaml), or use a +matching schema from the target application, installed distribution, or source +checkout. Never substitute the bundled main schema for a released version. + +The complete selected schema controls every structural capability, not only the +Action trigger field. For example, `0.3.0` uses `listen_to` and has no Tool +`injected_args`, while the bundled main schema uses `trigger_conditions` and +supports `injected_args`. Do not copy a field merely because it appears in another +version's example. Target-version provider metadata separately defines additional +arguments forwarded by Resource descriptors. + +If the manifest marks the selected version as lacking a YAML API, do not offer YAML +at the API gate. If a requested version has no exact schema, stop YAML generation +and report the missing contract. + +The valid top-level sections are: + +```text +agents, actions, chat_model_connections, chat_model_setups, +embedding_model_connections, embedding_model_setups, prompts, tools, +skills, vector_stores, mcp_servers +``` + +Top-level Resources and Actions are shared. The same sections nested under an +`agents[]` entry belong to that Agent. There is no `resources:` wrapper and no Agent +`type` field. + +## Versioned Workflow Shape + +These examples deliberately show only the Action structure. They contain no model, +Prompt, Tool, Skill, MCP server, or vector store because those Resources must come +from the staged user interview. Generate `app.actions` with matching skeletons; do +not infer what the Action emits. + +Flink Agents `0.3.0`: + +```yaml +agents: + - name: application_agent + + actions: + - name: process_input + function: app.actions:process_input + listen_to: [input] + type: python +``` + +Bundled `main` snapshot: + +```yaml +agents: + - name: application_agent + + actions: + - name: process_input + function: app.actions:process_input + trigger_conditions: [input] + type: python +``` + +Add downstream Actions only after their trigger events are confirmed. A skeleton's +existence proves only that the YAML reference resolves; it does not prove an event +is emitted. + +## Section Rules + +### Actions + +An inline Action requires `name`, the selected schema's non-empty trigger field, and +a valid `function` for custom behavior. `config` is optional. An Agent may also +reference a top-level shared Action by a bare string. + +Flink Agents `0.3.0` shared Action: + +```yaml +actions: + - name: shared_input + function: app.actions:shared_input + listen_to: [input] + +agents: + - name: first_agent + actions: [shared_input] +``` + +Bundled `main` snapshot: + +```yaml +actions: + - name: shared_input + function: app.actions:shared_input + trigger_conditions: [input] + +agents: + - name: first_agent + actions: [shared_input] +``` + +Event aliases include `input`, `output`, `chat_request`, `chat_response`, +`tool_request`, `tool_response`, `context_retrieval_request`, and +`context_retrieval_response`. Use the exact `EVENT_TYPE` string for custom events. + +### Function References + +Use exactly one colon: + +```yaml +# Python top-level function +function: app.actions:process_input + +# Python static method +function: app.actions:ReviewActions.process_input + +# Java static method +function: com.example.agent.Actions:processInput + +# Java nested class +function: com.example.Outer$Actions:processInput +``` + +The left side is a Python module or Java class FQN; the right side is the qualname. +Do not use a single dotted path in place of the colon. + +### Prompts and Tools + +A Prompt has exactly one of `text` or `messages`. Template substitution uses +`{name}`. A Tool needs a callable `function`; Java Tools also need ordered +`parameter_types`, while Python Tools must not declare them. + +```yaml +tools: + - name: add + type: java + function: com.example.Tools:add + parameter_types: [java.lang.Integer, java.lang.Integer] +``` + +The example shows the YAML shape, not a required business interview. If the user +requested a Tool capability but did not supply its contract, generate a capability- +derived name and a neutral one-string-input/one-string-output function skeleton. +For Java, set `parameter_types: [java.lang.String]`; for Python, omit +`parameter_types`. Put the unknown request fields, platform client, authentication, +query, and response mapping in TODOs. Do not ask the user to choose those details +before generating the project. + +### Descriptor-backed Resources + +Chat-model and embedding connections/setups, vector stores, and MCP servers require +`name` and `clazz`. Their remaining fields are forwarded to the provider. Use only +arguments documented for the selected provider and language. Resolve one Resource +at a time: ask for `clazz` first, inspect that target-version implementation, then +generate its required arguments as `TODO_REQUIRED_*` fields. Do not ask the user for +those values, and do not copy a model, endpoint, or authentication setting from this +or another example. If target-version artifacts do not reveal the implementation +class, dependency coordinate, constructor, descriptor, or mandatory arguments, +preserve existing working configuration and stop YAML work that depends on that +Provider. Report the sources checked, exact missing contract, and minimum artifact +needed to continue; do not generate a speculative descriptor. + +For chat-model connections/setups, embedding-model connections/setups, and vector +stores, the descriptor's `type` is independent of the custom Action/Tool and Flink +entry-point language. Offer aliases from both Python and Java buckets when the +target-version loader exposes the corresponding wrapper. A Python-loaded YAML may +use `type: java`; a Java-loaded YAML may use `type: python`. Set `type` explicitly +from the selected Resource implementation and add its integration/runtime artifact. +Do not apply this cross-language rule to MCP servers, Skills, Tools, Prompts, or +other Resource types without verified bridge support. + +The required `name` is an internal YAML identifier, not automatically a user +decision. For one Resource of a role, generate the defaults documented in +`application-patterns.md`, including `chat_model_connection` and `chat_model`, and +write the matching Setup `connection` reference directly. Preserve existing names. +Ask for naming input only to resolve multiple-resource ambiguity, satisfy an exact +external reference, or follow a user-requested convention. + +The bundled YAML loader has no general environment-variable interpolation for +descriptor arguments. Do not turn an unknown credential into `${API_KEY}` or claim +that it will be resolved. A literal provider argument in YAML is different from +interpolation: when the user chooses plaintext for local testing, write the literal +value to an untracked local YAML using the provider's documented field, such as +`api_key`, and load that YAML directly. Do not force programmatic `addResource` +registration merely because interpolation is unavailable. + +Keep the local YAML outside tracked application resources where practical, add its +exact path or pattern to `.gitignore`, verify it is ignored, and ensure the local +runner or command loads that file. Warn once that the file and any locally built +artifact containing it hold plaintext. Never echo or reproduce the value in logs, +commands, diffs shown to the user, or the final report. Programmatic registration +and provider-supported secret stores remain valid alternatives, not mandatory +replacements for the user's local-testing choice. + +MCP prompts and Tools are discovered dynamically and referenced by each prompt or +Tool's advertised name, just like local Prompts and Tools. The MCP server Resource +name is separate. Verify discovered names and collisions against the MCP server; +the static schema cannot do so. + +### Runtime Skills + +At least one source list must be non-empty. YAML describes the selected source; it +does not decide how Skills are deployed: + +| YAML field | Runtime scheme | Supported loader | Required deployment condition | +|---|---|---|---| +| `paths` | `local` | Python and Java | Each TaskManager can resolve the directory or ZIP path | +| `urls` | `url` | Python and Java | Each TaskManager can reach the HTTP(S) ZIP URL | +| `classpath` | `classpath` | Java only | Skill resources are present on the runtime classpath, normally in the application JAR | +| `package` | `package` | Python only | Skill resources are package data in an installed Python package/wheel | + +Preserve an existing explicit source unless the user requests a change. For a new +application, do not ask for source paths, URLs, packages, classpath locations, or +distribution. Generate a visibly unresolved declaration that lists the valid forms: + +```yaml +skills: + - name: runtime_skills + # TODO(required): replace this placeholder with the chosen source. + # Supported forms: paths, urls, Python package, or Java classpath. + paths: [TODO_REQUIRED_SKILL_SOURCE] +``` + +`paths` is a schema-shaped placeholder in this scaffold, not a selected deployment +mode. The generated application is not runtime-ready until the user replaces it. +Do not package, mount, download, or validate a source that the user has not supplied. + +Do not choose `package` merely because the implementation is Python or `classpath` +merely because it is Java. Language only constrains the valid bundled scheme. Use +`paths` or `urls` for a supported cross-language source. A relative `paths` entry is +resolved in the TaskManager runtime, not guaranteed to be the submitting client's +working directory; success in a local MiniCluster does not prove cluster-wide path +availability. + +Multiple source fields can coexist when the user explicitly requests composition. +The loaders append sources in `paths`, `urls`, `classpath`, `package` order, and a +later source replaces an earlier registration with the same Skill frontmatter name. +Avoid duplicate names and implicit fallback behavior. An unsupported scheme fails +at load time; a runtime does not skip it as a fallback. Prefer immutable, versioned +URLs; the YAML schema does not provide a checksum field, so do not invent one. + +Declaring a Skills Resource only makes Skills available. To activate one after its +business content and source are filled, add its `SKILL.md` name to a chat model +setup's `skills` list. Add `allowed_commands` only when the user later supplies +Skill behavior requiring shell operations; `load_skill` and `bash` are added +automatically. + +Runtime Skill instructions are business content. Derive minimal metadata from the +stated capability and leave a focused TODO body. Do not ask implementation questions +or invent runbooks, diagnostic rules, tool sequences, shell commands, or safety +policy. Never inspect or reuse a coding-agent host Skill such as a locally installed +`flink-diag`; host Skills and Flink runtime Skills have different owners and +deployment contracts. + +```markdown +--- +name: capability-derived-skill-name +description: TODO: Refine the runtime Skill purpose and trigger. +--- + +# Capability-derived Skill Name + +TODO: Define the domain workflow, evidence rules, and permitted tools. +``` + +## Language Selection + +Missing `type` means `python` for both loaders. Mark each Action, Tool, and +descriptor-backed Resource with its own implementation language rather than copying +one application-wide value. The bundled snapshot supports bidirectional bridging +for chat-model connections/setups, embedding-model connections/setups, and vector +stores. Consult the target version's YAML docs and plan compiler before assuming +that another Resource type bridges languages. + +The schema default is not a product decision. For a new application, first confirm +the Flink Agents/Flink pair and then the YAML API choice. Only then ask the user to +choose Python or Java before generating functions, Resources, dependencies, or the +Flink entry point. Use the interaction path selected by +[Interaction Discipline](../SKILL.md#interaction-discipline), not an open-ended +language question. Treat Python and Java as equal peer options: use parallel +descriptions, label neither one `(Recommended)`, and preselect neither one. Apply +the user's choice to custom Action/Tool implementations and the Flink entry point, +and do not mention a Python/JDK version before it. Preserve explicit language +choices in existing YAML. Do not require a separate cross-language confirmation +when the user selects a bridge-supported Resource implementation; that Resource's +selector already records the choice. + +In the current source, Java `AgentPlan` rejects MCP servers added through +`Agent.addResource`; Java `YamlLoader` represents YAML `mcp_servers` through that +path. Therefore, do not claim that a Java-loaded YAML application with +`mcp_servers` is runnable, regardless of `type`. Use the documented Java +`@MCPServer` programmatic definition for that application, or verify that the +target version has removed this restriction. Do not invent a YAML/MCP bridge or +adapter. + +## Name-resolution Pass + +Before loading, build and check this graph: + +- setup `connection` -> declared connection name; +- setup `prompt` -> declared Prompt name; +- setup `tools[]` -> local Tool or MCP-discovered Tool name; +- setup `skills[]` -> individual runtime Skill frontmatter name; +- embedding setup `connection` -> embedding connection name; +- vector-store `embedding_model` -> embedding setup name; +- `ChatRequestEvent.model` in implementation code -> chat setup name; +- Action and Tool `function` -> importable Python callable or Java static method. + +Schema validation cannot prove every dynamic name or provider argument. Run the +loader/build checks from `verification.md` after schema validation. diff --git a/dist/common/pom.xml b/dist/common/pom.xml index 2d6b6dbc1..ad00b6914 100644 --- a/dist/common/pom.xml +++ b/dist/common/pom.xml @@ -79,15 +79,6 @@ under the License. false false - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - org.apache.flink:flink-agents-* diff --git a/dist/pom.xml b/dist/pom.xml index 9ee655bb2..09779d5b7 100644 --- a/dist/pom.xml +++ b/dist/pom.xml @@ -135,6 +135,35 @@ under the License. org.apache.maven.plugins maven-shade-plugin + + + + org.apache.logging.log4j:* + org.slf4j:* + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + org/apache/logging/log4j/** + org/slf4j/** + log4j2*.xml + log4j2*.properties + log4j2*.json + log4j2*.yaml + log4j2*.yml + META-INF/org/apache/logging/log4j/** + META-INF/services/org.apache.logging.log4j.* + META-INF/licenses/LICENSE.slf4j* + + + + shade-flink-agents @@ -145,16 +174,6 @@ under the License. false false - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - @@ -168,4 +187,4 @@ under the License. - + \ No newline at end of file diff --git a/dist/src/main/resources/META-INF/NOTICE b/dist/src/main/resources/META-INF/NOTICE index afc811a14..0209a104d 100644 --- a/dist/src/main/resources/META-INF/NOTICE +++ b/dist/src/main/resources/META-INF/NOTICE @@ -14,9 +14,6 @@ This project bundles the following dependencies under the Apache Software Licens - com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2 - com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2 - com.fasterxml:classmate:1.7.0 -- org.apache.logging.log4j:log4j-api:2.23.1 -- org.apache.logging.log4j:log4j-core:2.23.1 -- org.apache.logging.log4j:log4j-slf4j-impl:2.23.1 - org.apache.kafka:kafka-clients:4.0.0 - org.lz4:lz4-java:1.8.0 - org.xerial.snappy:snappy-java:1.1.10.5 @@ -145,7 +142,6 @@ This project bundles the following dependencies under the Apache Software Licens This project bundles the following dependencies under the MIT license. See bundled license files for details. -- slf4j-api:slf4j-api:1.7.36 - io.github.ollama4j:ollama4j:1.1.5 - org.jsoup:jsoup:1.21.2 - com.anthropic:anthropic-java:2.11.1 diff --git a/docs/content/docs/development/yaml.md b/docs/content/docs/development/yaml.md index 87a9d7695..15c2a5c54 100644 --- a/docs/content/docs/development/yaml.md +++ b/docs/content/docs/development/yaml.md @@ -529,7 +529,7 @@ Common chat-model aliases: | `openai_responses` | — | OpenAI Responses (Java) | | `anthropic` | Anthropic | Anthropic | | `azure_openai` | Azure OpenAI (Python) | — | -| `azure` | — | Azure OpenAI (Java) | +| `azure` | — | Azure AI (Java) | | `bedrock` | — | Bedrock (Java) | | `tongyi` | Tongyi (Python) | — | diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml b/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml index 479a47ba5..18f792cbe 100644 --- a/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml +++ b/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml @@ -158,29 +158,8 @@ under the License. ${flink.1.20.version} flink-agents-dist-flink-1.20 + 2.24.3 - - - - org.apache.logging.log4j - log4j-api - ${log4j2.version} - test - - - org.apache.logging.log4j - log4j-core - ${log4j2.version} - test - - - org.apache.logging.log4j - log4j-slf4j-impl - ${log4j2.version} - test - - @@ -189,6 +168,7 @@ under the License. ${flink.2.0.version} flink-agents-dist-flink-2.0 + 2.24.3 @@ -198,6 +178,7 @@ under the License. ${flink.2.1.version} flink-agents-dist-flink-2.1 + 2.24.3 @@ -207,8 +188,9 @@ under the License. ${flink.2.2.version} flink-agents-dist-flink-2.2 + 2.24.3 - + \ No newline at end of file diff --git a/e2e-test/pom.xml b/e2e-test/pom.xml index 4c9fb14dc..fd387d0d6 100644 --- a/e2e-test/pom.xml +++ b/e2e-test/pom.xml @@ -28,6 +28,33 @@ under the License. flink-agents-e2e-tests pom Flink Agents : E2E Tests: + + + + 2.25.3 + + + + + org.apache.logging.log4j + log4j-api + ${flink.log4j2.version} + test + + + org.apache.logging.log4j + log4j-core + ${flink.log4j2.version} + test + + + org.apache.logging.log4j + log4j-slf4j-impl + ${flink.log4j2.version} + test + + + flink-agents-end-to-end-tests-agent-plan-compatibility flink-agents-end-to-end-tests-integration diff --git a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java index b0fb30df9..e445fa276 100644 --- a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java +++ b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java @@ -48,6 +48,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -430,7 +431,9 @@ Part convertToolCallToPart(Map call) { FunctionCall.Builder fcBuilder = FunctionCall.builder().name(functionName).args(argsMap); Object originalId = call.get("original_id"); - if (originalId != null) { + // A synthetic id exists only for runtime correlation (the API omitted the native id); + // echoing a fabricated id back to Gemini would claim the model produced it. + if (originalId != null && !Boolean.TRUE.equals(call.get("synthetic_id"))) { fcBuilder.id(originalId.toString()); } @@ -504,6 +507,17 @@ Map convertFunctionCall(FunctionCall functionCall, byte[] though if (id != null) { toolCall.put("id", id); toolCall.put("original_id", id); + } else { + // The Gemini Developer API frequently omits functionCall.id. Downstream correlation + // still needs one: ToolCallAction keys its result maps on `id` (two id-less parallel + // calls would otherwise collide on the literal "null") and only propagates + // `original_id` as the TOOL message's externalId, which is how the follow-up turn + // recovers the function name for Gemini's functionResponse part. Synthesize an id for + // the runtime round-trip and mark it so it is never echoed back to the API. + String syntheticId = UUID.randomUUID().toString(); + toolCall.put("id", syntheticId); + toolCall.put("original_id", syntheticId); + toolCall.put("synthetic_id", Boolean.TRUE); } toolCall.put("type", "function"); toolCall.put("function", functionMap); diff --git a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java index abc616f62..41dbdbe23 100644 --- a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java +++ b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java @@ -223,6 +223,71 @@ void testConvertFunctionCallNoSignature() { assertThat(toolCall).doesNotContainKey("thought_signature"); } + @Test + @DisplayName( + "convertFunctionCall synthesizes a unique id when the API omits functionCall.id, so" + + " parallel id-less calls cannot collide") + void testConvertFunctionCallWithoutIdSynthesizesUniqueIds() { + // The Gemini Developer API frequently returns functionCall parts with no id. + FunctionCall first = FunctionCall.builder().name("get_weather").args(Map.of()).build(); + FunctionCall second = FunctionCall.builder().name("get_time").args(Map.of()).build(); + + GeminiChatModelConnection conn = connection(); + Map firstCall = conn.convertFunctionCall(first, null); + Map secondCall = conn.convertFunctionCall(second, null); + + assertThat(firstCall.get("id")).isNotNull(); + assertThat(firstCall.get("original_id")).isEqualTo(firstCall.get("id")); + assertThat(firstCall).containsEntry("synthetic_id", Boolean.TRUE); + // ToolCallAction keys success/responses/error on `id`; distinct ids are what prevent two + // parallel id-less calls from overwriting each other. + assertThat(firstCall.get("id")).isNotEqualTo(secondCall.get("id")); + } + + @Test + @DisplayName("A synthetic id is never echoed back to the Gemini API on replay") + void testSyntheticIdNotEchoedToGemini() { + FunctionCall fc = FunctionCall.builder().name("get_weather").args(Map.of()).build(); + + GeminiChatModelConnection conn = connection(); + Map toolCall = conn.convertFunctionCall(fc, null); + Part part = conn.convertToolCallToPart(toolCall); + + FunctionCall replayed = part.functionCall().orElseThrow(); + assertThat(replayed.id()).isEmpty(); + assertThat(replayed.name()).hasValue("get_weather"); + } + + @Test + @DisplayName( + "Second turn resolves the function name via the synthetic id (full id-less round" + + " trip)") + void testSyntheticIdResolvesFunctionNameOnSecondTurn() { + FunctionCall fc = FunctionCall.builder().name("get_weather").args(Map.of()).build(); + + GeminiChatModelConnection conn = connection(); + Map toolCall = conn.convertFunctionCall(fc, null); + String syntheticId = (String) toolCall.get("original_id"); + + // Assistant turn carrying the id-less tool call, exactly as convertResponse builds it. + ChatMessage assistant = ChatMessage.assistant(""); + assistant.setToolCalls(List.of(toolCall)); + + // Runtime contract: ToolCallAction copies `original_id` into the TOOL message's + // `externalId`. Before the fix, no id existed, externalId was never set, and this + // second-turn conversion threw "Tool message must carry the function name". + ChatMessage tool = ChatMessage.tool("sunny, 22C"); + tool.getExtraArgs().put("externalId", syntheticId); + + Map idToName = + GeminiChatModelConnection.buildToolCallIdToNameMap(List.of(assistant, tool)); + Content content = conn.convertToContent(tool, idToName); + + Part part = content.parts().orElseThrow().get(0); + assertThat(part.functionResponse()).isPresent(); + assertThat(part.functionResponse().orElseThrow().name()).hasValue("get_weather"); + } + @Test @DisplayName("Tool-call round-trip preserves name, args and thoughtSignature") void testToolCallRoundTrip() { diff --git a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java index 8626b1b80..31b2c5ab8 100644 --- a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java +++ b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java @@ -25,10 +25,12 @@ import com.openai.azure.credential.AzureApiKeyCredential; import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.core.JsonSchemaLocalValidation; import com.openai.core.JsonValue; import com.openai.models.ChatModel; import com.openai.models.FunctionDefinition; import com.openai.models.FunctionParameters; +import com.openai.models.ResponseFormatJsonSchema; import com.openai.models.chat.completions.ChatCompletion; import com.openai.models.chat.completions.ChatCompletionCreateParams; import com.openai.models.chat.completions.ChatCompletionFunctionTool; @@ -47,6 +49,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; /** * Chat model integration for Azure OpenAI Service. Built on the openai-java SDK using its built-in @@ -98,8 +101,49 @@ public class AzureOpenAIChatModelConnection extends BaseChatModelConnection { private static final Set RESERVED_KWARG_KEYS = Set.of("model", "model_of_azure_deployment", "temperature", "max_tokens", "logprobs"); + // Models that both have documented json_schema strict Structured Outputs support and are served + // on the Chat Completions API, which is the API this connection calls. The set is that + // intersection, taken from two sources: + // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/structured-outputs lists the + // models supporting Structured Outputs on any API, and + // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/reasoning carries the + // per-model feature table whose "Chat Completions API" row excludes the models Azure serves + // only on the Responses API. + // + // Matching is exact, never by prefix: Azure exposes a deployment's model name and model version + // as separate properties, so a name carries no version to discriminate on. The documented list + // includes gpt-4o only at versions 2024-08-06 and 2024-11-20 while version 2024-05-13 is + // unsupported, so a bare "gpt-4o" is ambiguous and is deliberately absent from the set below. + // An unrecognized name reports not-capable and degrades to the prompt fallback rather than + // failing at the provider. + private static final Set NATIVE_STRUCTURED_OUTPUT_MODELS = + Set.of( + "gpt-5.1", + "gpt-5.1-chat", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "o3-mini", + "o1", + "gpt-4o-mini", + "gpt-4.1", + "gpt-4.1-nano", + "gpt-4.1-mini", + "o4-mini", + "o3"); + + // Date prefix of 2024-08-01-preview, the earliest api-version Azure documents as supporting + // structured outputs. + private static final String MIN_STRUCTURED_OUTPUT_API_VERSION = "2024-08-01"; + + // Leading zero-padded YYYY-MM-DD date of the api-version form Azure documents, which is a date + // optionally carrying a suffix such as -preview. + private static final Pattern API_VERSION_DATE_PREFIX = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}"); + private final OpenAIClient client; + private final String apiVersion; + public AzureOpenAIChatModelConnection( ResourceDescriptor descriptor, ResourceContext resourceContext) { super(descriptor, resourceContext); @@ -113,6 +157,7 @@ public AzureOpenAIChatModelConnection( if (apiVersion == null || apiVersion.isBlank()) { throw new IllegalArgumentException("api_version should not be null or empty."); } + this.apiVersion = apiVersion; String azureEndpoint = descriptor.getArgument("azure_endpoint"); if (azureEndpoint == null || azureEndpoint.isBlank()) { @@ -151,84 +196,240 @@ public AzureOpenAIChatModelConnection( this.client = clientBuilder.build(); } + /** + * Whether Azure documents json_schema strict support for {@code effectiveModel}. + * + *

{@code effectiveModel} is the model backing an Azure deployment, not the deployment name. + * See the allowlist above for the source of truth and for why the match is exact. An + * unrecognized model reports {@code false} so it degrades to the prompt-engineering fallback + * rather than failing at the provider. + * + *

Reads no instance state, so capability stays answerable independently of how the + * connection was configured. + */ + @Override + protected boolean supportsNativeStructuredOutput(String effectiveModel) { + if (effectiveModel == null || effectiveModel.isEmpty()) { + return false; + } + return NATIVE_STRUCTURED_OUTPUT_MODELS.contains(effectiveModel); + } + + /** + * Whether the configured api-version reaches the structured-output floor. + * + *

Azure documents {@code 2024-08-01-preview} as the first api-version supporting structured + * outputs, and whether an older version rejects {@code response_format} or silently ignores it + * is not documented. The request therefore never carries {@code response_format} below the + * floor, which is safe under either behavior. + * + *

Only the documented api-version form is classified, a zero-padded {@code YYYY-MM-DD} date + * optionally suffixed {@code -preview}; over that form comparing the leading date + * lexicographically is exact. A value of any other shape, including the GA {@code v1} literal, + * reports {@code false} and keeps the prompt fallback. That is the right answer for {@code v1} + * under the default {@code AUTO} path mode against a resource endpoint, where the request is + * built on the deployment-scoped path {@code /openai/deployments/{deployment}/chat/completions} + * with the api-version carried as a query parameter, so the literal is sent as {@code + * ?api-version=v1} rather than selecting Azure's {@code /openai/v1} endpoint. Under {@code + * UNIFIED}, or an endpoint already ending in {@code /openai/v1}, the request does reach the + * unified endpoint, and reporting not-capable there costs only the prompt fallback. The + * constructor rejects a null or blank api-version, so no value of that shape reaches here. + */ + private boolean apiVersionSupportsStructuredOutput() { + if (!API_VERSION_DATE_PREFIX.matcher(apiVersion).lookingAt()) { + return false; + } + return apiVersion + .substring(0, MIN_STRUCTURED_OUTPUT_API_VERSION.length()) + .compareTo(MIN_STRUCTURED_OUTPUT_API_VERSION) + >= 0; + } + @Override public ChatMessage chat( List messages, List tools, Map modelParams) { + return doChat(messages, tools, modelParams, null); + } + + /** + * Translates {@code outputSchema} into Azure's native strict {@code response_format} + * json_schema when it is a POJO {@link Class}, the model backing the deployment is one Azure + * documents json_schema strict support for, and the configured api-version reaches {@code + * 2024-08-01-preview}. Any other combination leaves the request unconstrained so that the + * prompt-engineering fallback still governs the response, rather than failing at the provider. + * + *

Capability is keyed on the {@code model_of_azure_deployment} model parameter rather than + * on the deployment the request targets, because a deployment name is chosen by the user and + * carries no model information. Leaving that parameter unset therefore keeps even a capable + * deployment on the fallback. + * + * @throws IllegalArgumentException if the schema is applied natively while {@code + * additional_kwargs} also carries a {@code response_format}, since the two would otherwise + * compete on the same request + */ + @Override + public ChatMessage chat( + List messages, + List tools, + Map modelParams, + Object outputSchema) { + return doChat(messages, tools, modelParams, outputSchema); + } + + private ChatMessage doChat( + List messages, + List tools, + Map modelParams, + Object outputSchema) { try { - Map mutableArgs = - modelParams != null ? new HashMap<>(modelParams) : new HashMap<>(); + ChatCompletionCreateParams params = + buildRequest(messages, tools, modelParams, outputSchema); + return toResponse(client.chat().completions().create(params), modelParams); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to call Azure OpenAI chat completions API.", e); + } + } - String azureDeployment = (String) mutableArgs.remove("model"); - if (azureDeployment == null || azureDeployment.isBlank()) { - throw new IllegalArgumentException("model is required for Azure OpenAI API calls"); - } - String modelOfAzureDeployment = - (String) mutableArgs.remove("model_of_azure_deployment"); + // Package-private so response handling can be asserted against a constructed completion without + // issuing a live API call through the final OpenAI client. + ChatMessage toResponse(ChatCompletion completion, Map modelParams) { + // Read from the caller's map rather than the copy buildRequest consumed, and read without + // consuming: a caller may reuse the same map across calls. The map is assembled fresh for + // each call and no one retains it, so reading it once the response has arrived yields the + // same value as reading it before the request was issued. Token metrics report the model + // backing the deployment, which buildRequest only uses to decide capability. + String modelOfAzureDeployment = + modelParams != null ? (String) modelParams.get("model_of_azure_deployment") : null; + + ChatMessage response = + OpenAIChatCompletionsUtils.convertFromOpenAIMessage( + completion.choices().get(0).message()); + + if (modelOfAzureDeployment != null + && !modelOfAzureDeployment.isBlank() + && completion.usage().isPresent()) { + response.getExtraArgs().put("model_name", modelOfAzureDeployment); + response.getExtraArgs().put("promptTokens", completion.usage().get().promptTokens()); + response.getExtraArgs() + .put("completionTokens", completion.usage().get().completionTokens()); + } - ChatCompletionCreateParams.Builder builder = - ChatCompletionCreateParams.builder() - .model(ChatModel.of(azureDeployment)) - .messages(OpenAIChatCompletionsUtils.convertToOpenAIMessages(messages)); + return response; + } - if (tools != null && !tools.isEmpty()) { - builder.tools(convertTools(tools)); - } + // Package-private so the request body (including the native response_format) can be asserted + // without issuing a live API call through the final OpenAI client. + ChatCompletionCreateParams buildRequest( + List messages, + List tools, + Map rawModelParams, + Object outputSchema) { + Map mutableArgs = + rawModelParams != null ? new HashMap<>(rawModelParams) : new HashMap<>(); + + String azureDeployment = (String) mutableArgs.remove("model"); + if (azureDeployment == null || azureDeployment.isBlank()) { + throw new IllegalArgumentException("model is required for Azure OpenAI API calls"); + } + String modelOfAzureDeployment = (String) mutableArgs.remove("model_of_azure_deployment"); - Object temperature = mutableArgs.remove("temperature"); - if (temperature instanceof Number) { - builder.temperature(((Number) temperature).doubleValue()); - } + ChatCompletionCreateParams.Builder builder = + ChatCompletionCreateParams.builder() + .model(ChatModel.of(azureDeployment)) + .messages(OpenAIChatCompletionsUtils.convertToOpenAIMessages(messages)); - Object maxTokens = mutableArgs.remove("max_tokens"); - if (maxTokens instanceof Number) { - builder.maxCompletionTokens(((Number) maxTokens).longValue()); - } + if (tools != null && !tools.isEmpty()) { + builder.tools(convertTools(tools)); + } - Object logprobs = mutableArgs.remove("logprobs"); - if (Boolean.TRUE.equals(logprobs)) { - builder.logprobs(true); - } + // Capability belongs to the model backing the deployment, so it is the input to the check; + // the deployment name is chosen by the user and carries none. Native structured output + // applies only for a POJO Class schema — a RowTypeInfo (wrapped in OutputSchema) keeps the + // prompt-engineering fallback, as do an incapable model and an api-version below the floor. + // + // TODO(#912): the requested strategy is not visible here, so this re-check cannot tell an + // explicit NATIVE request apart from one that merely resolved to native. A caller asking + // for NATIVE therefore gets an unconstrained response instead of an error whenever this + // branch is skipped, which on Azure also happens when the api-version is below the floor + // or when model_of_azure_deployment is unset and capability cannot be resolved at all. + // Once strategy resolution is wired up, NATIVE must either bypass this re-check or fail + // explicitly. + String nativeSchemaName = null; + if (outputSchema instanceof Class + && supportsNativeStructuredOutput(modelOfAzureDeployment) + && apiVersionSupportsStructuredOutput()) { + Class schemaClass = (Class) outputSchema; + builder.responseFormat(toNativeResponseFormat(schemaClass)); + nativeSchemaName = schemaClass.getSimpleName(); + } - @SuppressWarnings("unchecked") - Map additionalKwargs = - (Map) mutableArgs.remove("additional_kwargs"); - if (additionalKwargs != null) { - Set collisions = new HashSet<>(additionalKwargs.keySet()); - collisions.retainAll(RESERVED_KWARG_KEYS); - if (!collisions.isEmpty()) { - throw new IllegalArgumentException( - "additional_kwargs must not contain reserved typed fields: " - + collisions - + ". Set these via the corresponding Setup field instead."); - } - for (Map.Entry entry : additionalKwargs.entrySet()) { - builder.putAdditionalBodyProperty( - entry.getKey(), toJsonValue(entry.getValue())); - } - } + Object temperature = mutableArgs.remove("temperature"); + if (temperature instanceof Number) { + builder.temperature(((Number) temperature).doubleValue()); + } - ChatCompletion completion = client.chat().completions().create(builder.build()); + Object maxTokens = mutableArgs.remove("max_tokens"); + if (maxTokens instanceof Number) { + builder.maxCompletionTokens(((Number) maxTokens).longValue()); + } - ChatMessage response = - OpenAIChatCompletionsUtils.convertFromOpenAIMessage( - completion.choices().get(0).message()); + Object logprobs = mutableArgs.remove("logprobs"); + if (Boolean.TRUE.equals(logprobs)) { + builder.logprobs(true); + } - if (modelOfAzureDeployment != null - && !modelOfAzureDeployment.isBlank() - && completion.usage().isPresent()) { - response.getExtraArgs().put("model_name", modelOfAzureDeployment); - response.getExtraArgs() - .put("promptTokens", completion.usage().get().promptTokens()); - response.getExtraArgs() - .put("completionTokens", completion.usage().get().completionTokens()); + @SuppressWarnings("unchecked") + Map additionalKwargs = + (Map) mutableArgs.remove("additional_kwargs"); + if (additionalKwargs != null) { + Set collisions = new HashSet<>(additionalKwargs.keySet()); + collisions.retainAll(RESERVED_KWARG_KEYS); + if (!collisions.isEmpty()) { + throw new IllegalArgumentException( + "additional_kwargs must not contain reserved typed fields: " + + collisions + + ". Set these via the corresponding Setup field instead."); + } + // Only the branch that actually sent a schema may reject the caller's own + // response_format; every path that skipped it leaves the value untouched. + if (nativeSchemaName != null && additionalKwargs.containsKey("response_format")) { + throw new IllegalArgumentException( + "The " + + nativeSchemaName + + " output schema is sent as response_format on deployment '" + + azureDeployment + + "', so response_format must not also be set in additional_kwargs." + + " Remove that value, or omit the output schema to set" + + " response_format directly."); + } + for (Map.Entry entry : additionalKwargs.entrySet()) { + builder.putAdditionalBodyProperty(entry.getKey(), toJsonValue(entry.getValue())); } - - return response; - } catch (IllegalArgumentException e) { - throw e; - } catch (Exception e) { - throw new RuntimeException("Failed to call Azure OpenAI chat completions API.", e); } + + return builder.build(); + } + + // Derives the strict json_schema response format from a POJO class via the SDK's typed + // structured-output builder. The Kotlin-facade StructuredOutputsKt.responseFormatFromClass is + // not callable from Java, so the response format is extracted through the typed builder, which + // generates the same strict draft-2020-12 schema, and then reattached to the standard builder. + private static ResponseFormatJsonSchema toNativeResponseFormat(Class schemaClass) { + return ChatCompletionCreateParams.builder() + .model(ChatModel.of("")) + .addUserMessage("") + .responseFormat(schemaClass, JsonSchemaLocalValidation.NO) + .build() + .rawParams() + .responseFormat() + .orElseThrow( + () -> + new IllegalStateException( + "OpenAI SDK did not produce a response_format for schema " + + schemaClass.getName())) + .asJsonSchema(); } @Override diff --git a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelSetup.java b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelSetup.java index 44a7c8431..d88d01cc1 100644 --- a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelSetup.java +++ b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelSetup.java @@ -32,7 +32,9 @@ * *

{@code model} (inherited from {@link BaseChatModelSetup}) is the Azure deployment name, not * the underlying OpenAI model name. The underlying model name can be supplied via {@code - * model_of_azure_deployment} and is used solely for token-metrics tracking. + * model_of_azure_deployment}. It labels token-usage metrics, and it is also the name that decides + * whether a request can carry a native structured-output schema, since the deployment name carries + * no model information. Leaving it unset keeps requests on the prompt-engineering fallback. * *

Example usage: * diff --git a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java index 60a29729b..97b9a2cb6 100644 --- a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java +++ b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java @@ -18,34 +18,107 @@ package org.apache.flink.agents.integrations.chatmodels.openai; +import com.fasterxml.jackson.core.type.TypeReference; +import com.openai.models.ChatModel; +import com.openai.models.ResponseFormatJsonSchema; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionMessage; +import com.openai.models.completions.CompletionUsage; import org.apache.flink.agents.api.chat.messages.ChatMessage; import org.apache.flink.agents.api.chat.messages.MessageRole; import org.apache.flink.agents.api.chat.model.BaseChatModelConnection; import org.apache.flink.agents.api.resource.ResourceContext; import org.apache.flink.agents.api.resource.ResourceDescriptor; +import org.apache.flink.agents.api.tools.Tool; +import org.apache.flink.agents.api.tools.ToolMetadata; +import org.apache.flink.agents.api.tools.ToolParameters; +import org.apache.flink.agents.api.tools.ToolResponse; +import org.apache.flink.agents.api.tools.ToolType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Unit tests for {@link AzureOpenAIChatModelConnection} — constructor validation only, no network - * access. End-to-end tests against a real Azure OpenAI deployment live in {@link - * AzureOpenAIChatModelIT}. + * Unit tests for {@link AzureOpenAIChatModelConnection} — constructor validation and request + * building, with no network access. End-to-end tests against a real Azure OpenAI deployment live in + * {@link AzureOpenAIChatModelIT}. */ class AzureOpenAIChatModelConnectionTest { private static final ResourceContext NOOP = ResourceContext.fromGetResource((a, b) -> null); + /** A deployment name is chosen by the user and carries no capability information. */ + private static final String DEPLOYMENT = "my-deployment"; + + private static final String CAPABLE_API_VERSION = "2024-08-01-preview"; + + private static final String BELOW_FLOOR_API_VERSION = "2024-02-01"; + + private static final Map CALLER_RESPONSE_FORMAT = Map.of("type", "json_object"); + private static ResourceDescriptor.Builder connectionDescriptor() { return ResourceDescriptor.Builder.newBuilder( AzureOpenAIChatModelConnection.class.getName()); } + /** A representative POJO output schema. */ + public static class Person { + public String name; + public int age; + } + + private static AzureOpenAIChatModelConnection connection(String apiVersion) { + ResourceDescriptor desc = + connectionDescriptor() + .addInitialArgument("api_key", "test-key") + .addInitialArgument("api_version", apiVersion) + .addInitialArgument("azure_endpoint", "https://example.openai.azure.com") + .build(); + return new AzureOpenAIChatModelConnection(desc, NOOP); + } + + private static AzureOpenAIChatModelConnection connection() { + return connection(CAPABLE_API_VERSION); + } + + /** + * Model params addressing {@link #DEPLOYMENT}. A null {@code modelOfAzureDeployment} omits the + * key entirely, which is how the setup emits an unset backing model. + */ + private static Map params(String modelOfAzureDeployment) { + Map params = new HashMap<>(); + params.put("model", DEPLOYMENT); + if (modelOfAzureDeployment != null) { + params.put("model_of_azure_deployment", modelOfAzureDeployment); + } + return params; + } + + private static Map paramsWithCallerResponseFormat( + String modelOfAzureDeployment) { + Map params = params(modelOfAzureDeployment); + params.put("additional_kwargs", Map.of("response_format", CALLER_RESPONSE_FORMAT)); + return params; + } + + private static List userMessage() { + return List.of(new ChatMessage(MessageRole.USER, "hi")); + } + @Test @DisplayName("Constructor throws when api_key is missing") void testConstructorMissingApiKey() { @@ -128,4 +201,356 @@ void testChatRejectsReservedKeyInAdditionalKwargs() { .hasMessageContaining("additional_kwargs") .hasMessageContaining("temperature"); } + + @Test + @DisplayName("Native response_format json_schema strict applied for a POJO on a capable model") + void testNativeAppliedForCapableDeploymentModel() { + ChatCompletionCreateParams request = + connection() + .buildRequest( + userMessage(), List.of(), params("gpt-4o-mini"), Person.class); + + assertThat(request.responseFormat()).isPresent(); + ResponseFormatJsonSchema jsonSchema = request.responseFormat().get().asJsonSchema(); + // The SDK derives the wire name from the class, so it identifies the schema without being + // equal to the class name. + assertThat(jsonSchema.jsonSchema().name()).contains("Person"); + assertThat(jsonSchema.jsonSchema().strict()).contains(true); + } + + @Test + @DisplayName("A native request still targets the deployment, not the backing model") + void testCapableNativeRequestStillTargetsTheDeployment() { + // Capability is keyed on the backing model, but the provider is still addressed by + // deployment; substituting one for the other would route the call to a deployment that may + // not exist on the resource. + ChatCompletionCreateParams request = + connection() + .buildRequest( + userMessage(), List.of(), params("gpt-4o-mini"), Person.class); + + assertThat(request.model()).isEqualTo(ChatModel.of(DEPLOYMENT)); + } + + @Test + @DisplayName("Native NOT applied when the backing model of the deployment is absent") + void testNativeNotAppliedWhenDeploymentModelAbsent() { + ChatCompletionCreateParams request = + connection().buildRequest(userMessage(), List.of(), params(null), Person.class); + + assertThat(request.responseFormat()).isEmpty(); + } + + @Test + @DisplayName("Native NOT applied for a backing model outside the allowlist") + void testNativeNotAppliedForUnknownDeploymentModel() { + ChatCompletionCreateParams request = + connection() + .buildRequest( + userMessage(), + List.of(), + params("some-unknown-model"), + Person.class); + + assertThat(request.responseFormat()).isEmpty(); + } + + @Test + @DisplayName("Native NOT applied for a bare gpt-4o backing model") + void testNativeNotAppliedForBareGpt4o() { + // Azure carries model name and model version as separate properties, so a bare gpt-4o may + // be the 2024-05-13 version, which predates structured-output support. + ChatCompletionCreateParams request = + connection().buildRequest(userMessage(), List.of(), params("gpt-4o"), Person.class); + + assertThat(request.responseFormat()).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-08-01", "2024-10-21"}) + @DisplayName("Native applied for a bare GA date at or above the floor") + void testNativeAppliedForGaDateAtOrAboveFloor(String apiVersion) { + // The documented floor is the preview form 2024-08-01-preview, so these pin that a bare GA + // date carrying no -preview suffix is admitted, and that 2024-08-01 is the inclusive + // boundary. + ChatCompletionCreateParams request = + connection(apiVersion) + .buildRequest( + userMessage(), List.of(), params("gpt-4o-mini"), Person.class); + + assertThat(request.responseFormat()).isPresent(); + } + + @ParameterizedTest + @ValueSource(strings = {"v1", "latest"}) + @DisplayName("Native NOT applied for an api-version outside the documented dated form") + void testNativeNotAppliedForNonDateApiVersion(String apiVersion) { + // Every one of these sorts above the floor as a string, so only classifying the dated form + // keeps them out. The v1 literal in particular does not select Azure's v1 endpoint on the + // default path mode: it is sent as a query parameter on the deployment-scoped + // chat/completions path. + ChatCompletionCreateParams request = + connection(apiVersion) + .buildRequest( + userMessage(), List.of(), params("gpt-4o-mini"), Person.class); + + assertThat(request.responseFormat()).isEmpty(); + } + + @Test + @DisplayName("Native NOT applied when the configured api-version predates the floor") + void testNativeNotAppliedWhenApiVersionBelowFloor() { + // An absent api-version cannot reach this gate at all: the constructor rejects it, which + // testConstructorMissingApiVersion pins. + ChatCompletionCreateParams request = + connection(BELOW_FLOOR_API_VERSION) + .buildRequest( + userMessage(), List.of(), params("gpt-4o-mini"), Person.class); + + assertThat(request.responseFormat()).isEmpty(); + } + + @Test + @DisplayName("Native NOT applied when no output schema is supplied") + void testNativeNotAppliedWhenSchemaNull() { + ChatCompletionCreateParams request = + connection().buildRequest(userMessage(), List.of(), params("gpt-4o-mini"), null); + + assertThat(request.responseFormat()).isEmpty(); + } + + @Test + @DisplayName("Native NOT applied for a non-POJO schema form (POJO-only scope)") + void testNativeNotAppliedForNonPojoSchema() { + // A RowTypeInfo schema arrives wrapped in OutputSchema rather than as a bare POJO Class, so + // it must not activate native structured output. OutputSchema cannot be instantiated here + // because RowTypeInfo is not on this module's classpath; any non-Class schema object + // exercises the same gate. + Object nonClassSchema = "row"; + + ChatCompletionCreateParams request = + connection() + .buildRequest( + userMessage(), List.of(), params("gpt-4o-mini"), nonClassSchema); + + assertThat(request.responseFormat()).isEmpty(); + } + + @Test + @DisplayName("Native applied for a POJO even when tools are bound") + void testNativeAppliedEvenWhenToolsBound() { + // Azure documents structured outputs as unsupported with parallel function calls, which + // constrains strict tool schemas rather than the response_format this branch sets, so + // binding tools does not gate it. + ChatCompletionCreateParams request = + connection() + .buildRequest( + userMessage(), + List.of(new StubTool()), + params("gpt-4o-mini"), + Person.class); + + assertThat(request.responseFormat()).isPresent(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "gpt-5.1", + "gpt-5.1-chat", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "o3-mini", + "o1", + "gpt-4o-mini", + "gpt-4.1", + "gpt-4.1-nano", + "gpt-4.1-mini", + "o4-mini", + "o3" + }) + @DisplayName("Capability predicate accepts every documented capable Azure model name") + void testCapabilityPredicateAcceptsCapableModels(String model) { + // The list is the whole allowlist, so dropping an entry is caught rather than only + // narrowing capability silently. + assertThat(connection().supportsNativeStructuredOutput(model)).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource( + strings = { + "gpt-4o", + "gpt-35-turbo", + "gpt-4", + "gpt-4o-2024-08-06", + "some-unknown-model", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5-pro", + "gpt-5-codex", + "codex-mini", + "o3-pro" + }) + @DisplayName("Capability predicate rejects incapable, Responses-only, and empty names") + void testCapabilityPredicateRejectsIncapableModels(String model) { + // A version-suffixed value such as gpt-4o-2024-08-06 is an OpenAI snapshot name, not a name + // Azure reports as the model behind a deployment. The codex, gpt-5-pro and o3-pro names do + // support structured outputs but are served only on the Responses API, so they are + // incapable on the chat completions API this connection calls. + assertThat(connection().supportsNativeStructuredOutput(model)).isFalse(); + } + + @Test + @DisplayName( + "A caller-supplied response_format alongside a natively applied schema is rejected") + void testCallerResponseFormatConflictsWithNativeSchema() { + // Both values would otherwise reach the same request, where the additional body property + // silently competes with the typed response_format the schema produced. + AzureOpenAIChatModelConnection conn = connection(); + Map args = paramsWithCallerResponseFormat("gpt-4o-mini"); + + assertThatThrownBy(() -> conn.chat(userMessage(), null, args, Person.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("response_format") + .hasMessageContaining("Person"); + } + + private static Stream nonNativePaths() { + return Stream.of( + Arguments.of("incapable_model", CAPABLE_API_VERSION, "gpt-4o", Person.class), + Arguments.of( + "non_pojo_schema", CAPABLE_API_VERSION, "gpt-4o-mini", "row"), + Arguments.of("no_output_schema", CAPABLE_API_VERSION, "gpt-4o-mini", null), + Arguments.of( + "api_version_below_floor", + BELOW_FLOOR_API_VERSION, + "gpt-4o-mini", + Person.class)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("nonNativePaths") + @DisplayName("A caller-supplied response_format survives wherever native output is skipped") + void testCallerResponseFormatSurvivesWhenNativeIsSkipped( + String label, String apiVersion, String modelOfAzureDeployment, Object outputSchema) { + // Only the branch that actually sends a schema as response_format may reject the caller's + // own value, so identical caller code has to keep working along every path that skips it, + // including the no-schema path taken by any caller that drives response_format itself. + ChatCompletionCreateParams request = + connection(apiVersion) + .buildRequest( + userMessage(), + List.of(), + paramsWithCallerResponseFormat(modelOfAzureDeployment), + outputSchema); + + assertThat(request.responseFormat()).isEmpty(); + assertThat(request._additionalBodyProperties()) + .hasEntrySatisfying( + "response_format", + value -> + assertThat( + value.convert( + new TypeReference< + Map>() {})) + .isEqualTo(CALLER_RESPONSE_FORMAT)); + } + + @Test + @DisplayName("Token metrics label the response with the model backing the deployment") + void testResponseCarriesBackingModelTokenMetrics() { + // The deployment name is what the request targets, but usage has to be attributed to the + // model behind it, which is the only name that identifies what actually ran. + ChatMessage response = + connection().toResponse(completionWithUsage(11L, 7L), params("gpt-4o-mini")); + + assertThat(response.getExtraArgs()) + .containsEntry("model_name", "gpt-4o-mini") + .containsEntry("promptTokens", 11L) + .containsEntry("completionTokens", 7L); + } + + @Test + @DisplayName("Response handling leaves the caller's model params intact") + void testResponseHandlingDoesNotConsumeCallerModelParams() { + // The backing model is read from the map the caller owns. Consuming the entry would strip + // token metrics from every later call that reuses the same map. + Map callerParams = params("gpt-4o-mini"); + + connection().toResponse(completionWithUsage(11L, 7L), callerParams); + + assertThat(callerParams).containsEntry("model_of_azure_deployment", "gpt-4o-mini"); + } + + private static Stream incompleteMetricsInputs() { + return Stream.of( + Arguments.of("backing_model_unset", completionWithUsage(11L, 7L), params(null)), + Arguments.of( + "completion_without_usage", + completionWithoutUsage(), + params("gpt-4o-mini"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("incompleteMetricsInputs") + @DisplayName("Token metrics are omitted when the backing model or the usage report is absent") + void testNoTokenMetricsWhenMetricsInputsAreIncomplete( + String label, ChatCompletion completion, Map modelParams) { + // Leaving the backing model unset is the documented default, and a completion may arrive + // without a usage report; both drop the metrics rather than costing the caller the reply. + ChatMessage response = connection().toResponse(completion, modelParams); + + assertThat(response.getExtraArgs()) + .doesNotContainKeys("model_name", "promptTokens", "completionTokens"); + } + + private static ChatCompletion completionWithUsage(long promptTokens, long completionTokens) { + return completionBuilder() + .usage( + CompletionUsage.builder() + .promptTokens(promptTokens) + .completionTokens(completionTokens) + .totalTokens(promptTokens + completionTokens) + .build()) + .build(); + } + + private static ChatCompletion completionWithoutUsage() { + return completionBuilder().build(); + } + + private static ChatCompletion.Builder completionBuilder() { + ChatCompletionMessage message = + ChatCompletionMessage.builder().content("hi").refusal(Optional.empty()).build(); + return ChatCompletion.builder() + .id("completion-1") + .created(0L) + .model(DEPLOYMENT) + .addChoice( + ChatCompletion.Choice.builder() + .finishReason(ChatCompletion.Choice.FinishReason.STOP) + .index(0L) + .logprobs(Optional.empty()) + .message(message) + .build()); + } + + /** Minimal tool stub; only its presence in the tools list matters. */ + private static class StubTool extends Tool { + StubTool() { + super(new ToolMetadata("add", "adds", "{\"type\":\"object\"}")); + } + + @Override + public ToolType getToolType() { + return ToolType.FUNCTION; + } + + @Override + public ToolResponse call(ToolParameters parameters) { + return ToolResponse.success(null); + } + } } diff --git a/plan/pom.xml b/plan/pom.xml index 02df3c2c3..133ce0b3b 100644 --- a/plan/pom.xml +++ b/plan/pom.xml @@ -85,16 +85,6 @@ under the License. slf4j-api ${slf4j.version} - - org.apache.logging.log4j - log4j-core - ${log4j2.version} - - - org.apache.logging.log4j - log4j-slf4j-impl - ${log4j2.version} - diff --git a/pom.xml b/pom.xml index 108841acd..b7774db7f 100644 --- a/pom.xml +++ b/pom.xml @@ -47,7 +47,7 @@ under the License. 5.10.1 2.18.2 0.5.7 - 2.23.1 + 2.24.3 1.7.36 3.27.7 5.14.2 diff --git a/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py b/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py index b653b4d67..422d9d09e 100644 --- a/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py +++ b/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py @@ -16,10 +16,18 @@ # limitations under the License. ################################################################################# import logging +import re from typing import Any, Dict, List, Sequence from openai import NOT_GIVEN, AzureOpenAI -from pydantic import Field, PrivateAttr + +# Private SDK module (leading underscore): the openai client itself uses this helper to +# build the strict json_schema for response_format, and there is no public re-export. It +# has existed at this path since the structured-output support in openai 1.66.3 (the +# pinned minimum). A future openai bump that moves it will fail loudly on import here. +from openai.lib._pydantic import to_strict_json_schema +from pydantic import BaseModel, Field, PrivateAttr +from typing_extensions import override from flink_agents.api.agents.types import OutputSchema from flink_agents.api.chat_message import ChatMessage @@ -40,6 +48,72 @@ {"model", "model_of_azure_deployment", "temperature", "max_tokens", "logprobs"} ) +# Models that both have documented json_schema strict Structured Outputs support and are +# served on the Chat Completions API, which is the API this connection calls. The set is +# that intersection, taken from two sources: +# https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/structured-outputs +# lists the models supporting Structured Outputs on any API, and +# https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/reasoning carries the +# per-model feature table whose "Chat Completions API" row excludes the models Azure +# serves only on the Responses API. +# +# Matching is exact, never by prefix: Azure exposes a deployment's model name and model +# version as separate properties, so a name carries no version to discriminate on. The +# documented list includes gpt-4o only at versions 2024-08-06 and 2024-11-20 while +# version 2024-05-13 is unsupported, so a bare "gpt-4o" is ambiguous and is deliberately +# absent from the set below. An unrecognized name reports not-capable and degrades to +# the prompt fallback rather than failing at the provider. +_NATIVE_STRUCTURED_OUTPUT_MODELS = frozenset( + { + "gpt-5.1", + "gpt-5.1-chat", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "o3-mini", + "o1", + "gpt-4o-mini", + "gpt-4.1", + "gpt-4.1-nano", + "gpt-4.1-mini", + "o4-mini", + "o3", + } +) + +# Date prefix of 2024-08-01-preview, the earliest api-version Azure documents as +# supporting structured outputs. +_MIN_STRUCTURED_OUTPUT_API_VERSION = "2024-08-01" + +# Leading zero-padded YYYY-MM-DD date of the api-version form Azure documents, which +# is a date optionally carrying a suffix such as -preview. The ASCII flag restricts \d +# to 0-9, so a value written with other Unicode decimal digits is not read as a date. +_API_VERSION_DATE_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}", re.ASCII) + + +def _native_response_format(output_schema: Any) -> Dict[str, Any] | None: + """Build the ``response_format`` for a native structured-output request. + + Returns ``None`` (leaving behavior unchanged) unless the schema is a ``BaseModel`` + subclass. A ``RowTypeInfo`` schema is skipped so it keeps the prompt-engineering + fallback. + """ + if output_schema is None: + return None + model = ( + output_schema.output_schema if isinstance(output_schema, OutputSchema) else None + ) + if not (isinstance(model, type) and issubclass(model, BaseModel)): + return None + return { + "type": "json_schema", + "json_schema": { + "name": model.__name__, + "schema": to_strict_json_schema(model), + "strict": True, + }, + } + class AzureOpenAIChatModelConnection(BaseChatModelConnection): """The connection to the Azure OpenAI LLM. @@ -114,6 +188,46 @@ def client(self) -> AzureOpenAI: ) return self._client + @override + def supports_native_structured_output(self, effective_model: str | None) -> bool: + """Whether Azure documents json_schema strict support for ``effective_model``. + + ``effective_model`` is the model backing an Azure deployment, not the deployment + name. See the module-level allowlist for the source of truth and for why the + match is exact. An unrecognized model reports ``False`` so it degrades to the + prompt-engineering fallback rather than failing at the provider. + + Reads no instance state, so it stays answerable on an instance that was never + initialized, where any field access would raise. + """ + if not effective_model: + return False + return effective_model in _NATIVE_STRUCTURED_OUTPUT_MODELS + + def _api_version_supports_structured_output(self) -> bool: + """Whether the configured api-version reaches the structured-output floor. + + Azure documents ``2024-08-01-preview`` as the first api-version supporting + structured outputs, and whether an older version rejects ``response_format`` or + silently ignores it is not documented. The request therefore never carries + ``response_format`` below the floor, which is safe under either behavior. + + Only the documented api-version form is classified, a zero-padded + ``YYYY-MM-DD`` date optionally suffixed ``-preview``; over that form comparing + the leading date lexicographically is exact. A value of any other shape, + including the GA ``v1`` literal, reports ``False`` and keeps the prompt + fallback. That is the accurate answer for ``v1``: ``AzureOpenAI`` reaches the + service through the deployment-scoped path + ``/openai/deployments/{deployment}/chat/completions`` with the api-version + carried as a query parameter, so the ``v1`` literal is sent as + ``?api-version=v1`` rather than selecting Azure's ``/openai/v1`` endpoint. + """ + if not self.api_version: + return False + if not _API_VERSION_DATE_PREFIX.match(self.api_version): + return False + return self.api_version[:10] >= _MIN_STRUCTURED_OUTPUT_API_VERSION + def chat( self, messages: Sequence[ChatMessage], @@ -130,10 +244,13 @@ def chat( tools : Optional[List] List of tools that can be called by the model output_schema : OutputSchema | None - Rejected when non-``None``: this connection has no native structured-output - translation, so callers stay on the prompt-engineering fallback. - Declaring the parameter keeps a caller-supplied schema out of - ``**kwargs``, which is forwarded to the provider SDK. + The schema the response should conform to, or ``None`` for an unconstrained + response. Native structured output is applied only for a ``BaseModel`` + schema, on a deployment whose backing model the provider documents as + capable, and with an api-version that supports it; a ``RowTypeInfo`` schema, + an incapable model, or an older api-version keeps the prompt-engineering + fallback. Where native output applies, a caller-supplied + ``response_format`` conflicts with it and raises ``ValueError``. **kwargs : Any Additional parameters passed to the model service (e.g., temperature, max_tokens, etc.) @@ -143,7 +260,6 @@ def chat( ChatMessage Model response message """ - self._reject_unsupported_output_schema(output_schema) tool_specs = None if tools is not None: tool_specs = [to_openai_tool(metadata=tool.metadata) for tool in tools] @@ -165,6 +281,39 @@ def chat( ) raise ValueError(msg) + # Capability belongs to the model backing the deployment, so it is the input to + # the check. The deployment name is chosen by the user and carries none. + # + # TODO(#912): the requested strategy is not visible here, so this check cannot + # tell an explicit NATIVE request apart from one that merely resolved to native. + # A caller asking for NATIVE therefore gets an unconstrained response instead of + # an error whenever this branch is skipped, which on Azure also happens when the + # api-version is below the floor or when model_of_azure_deployment is unset and + # capability cannot be resolved at all. Once strategy resolution is wired up, + # NATIVE must either bypass this check or fail explicitly. + if ( + output_schema is not None + and self.supports_native_structured_output(model_of_azure_deployment) + and self._api_version_supports_structured_output() + ): + response_format = _native_response_format(output_schema) + if response_format is not None: + caller_response_format = ( + "response_format" in kwargs + or "response_format" in additional_kwargs + ) + if caller_response_format: + msg = ( + f"The {response_format['json_schema']['name']} output schema " + f"is sent as response_format on deployment " + f"'{azure_deployment}', so response_format must not also be " + f"passed as a kwarg or in additional_kwargs. Remove that " + f"value, or omit output_schema to set response_format " + f"directly." + ) + raise ValueError(msg) + kwargs["response_format"] = response_format + response = self.client.chat.completions.create( # Azure OpenAI APIs use Azure deployment name as the model parameter model=azure_deployment, diff --git a/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py new file mode 100644 index 000000000..063f3dc2c --- /dev/null +++ b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py @@ -0,0 +1,432 @@ +################################################################################ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################# +from typing import Any +from unittest.mock import MagicMock + +import pytest +from pydantic import BaseModel +from pyflink.common.typeinfo import Types + +from flink_agents.api.agents.types import OutputSchema +from flink_agents.api.chat_message import ChatMessage, MessageRole +from flink_agents.integrations.chat_models.azure.azure_openai_chat_model import ( + AzureOpenAIChatModelConnection, +) +from flink_agents.plan.function import PythonFunction +from flink_agents.plan.tools.function_tool import FunctionTool + +# A deployment name is chosen by the user and carries no capability information, so +# every chat() call here uses one that is not a model name. +DEPLOYMENT = "my-deployment" + +CAPABLE_API_VERSION = "2024-08-01-preview" + +BELOW_FLOOR_API_VERSION = "2024-02-01" + +CALLER_RESPONSE_FORMAT = {"type": "json_object"} + + +class Person(BaseModel): + """A representative BaseModel output schema.""" + + name: str + age: int + + +ROW_TYPE = Types.ROW_NAMED(["name"], [Types.STRING()]) + + +def _add(a: int, b: int) -> int: + """Add two integers. + + Parameters + ---------- + a : int + first + b : int + second + + Returns: + ------- + int + sum + """ + return a + b + + +def _connection( + api_version: str = CAPABLE_API_VERSION, +) -> AzureOpenAIChatModelConnection: + conn = AzureOpenAIChatModelConnection( + name="azure_openai", + api_key="test-key", + azure_endpoint="https://example.openai.azure.com", + api_version=api_version, + ) + mock_client = MagicMock() + mock_message = MagicMock() + mock_message.role = "assistant" + mock_message.content = "ok" + mock_message.tool_calls = None + mock_client.chat.completions.create.return_value.choices = [ + MagicMock(message=mock_message) + ] + mock_client.chat.completions.create.return_value.usage = None + conn._client = mock_client + return conn + + +def _create_call_kwargs(conn: AzureOpenAIChatModelConnection) -> dict[str, Any]: + return conn.client.chat.completions.create.call_args.kwargs + + +def _chat_with_caller_response_format( + conn: AzureOpenAIChatModelConnection, + *, + model_of_azure_deployment: str, + in_additional_kwargs: bool, + schema: Any = Person, +) -> None: + """Chat with a caller-supplied response_format, optionally with an output schema. + + The value travels either inside additional_kwargs or as a direct kwarg; both end + up in the same create() call. A ``schema`` of ``None`` sends no output schema at + all rather than an empty one. + """ + channel = ( + {"additional_kwargs": {"response_format": CALLER_RESPONSE_FORMAT}} + if in_additional_kwargs + else {"response_format": CALLER_RESPONSE_FORMAT} + ) + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment=model_of_azure_deployment, + output_schema=None if schema is None else OutputSchema(output_schema=schema), + **channel, + ) + + +def test_native_applied_for_capable_deployment_model() -> None: + """response_format json_schema strict applied for a BaseModel on a capable model.""" + conn = _connection() + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=Person), + ) + response_format = _create_call_kwargs(conn)["response_format"] + assert response_format["type"] == "json_schema" + assert response_format["json_schema"]["name"] == "Person" + assert response_format["json_schema"]["strict"] is True + assert response_format["json_schema"]["schema"]["additionalProperties"] is False + + +def test_capable_native_request_still_targets_the_deployment() -> None: + """The native branch leaves `model` as the deployment name. + + Capability is keyed on the backing model, but the provider is still addressed by + deployment; substituting one for the other would route the call to a deployment + that may not exist on the resource. + """ + conn = _connection() + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=Person), + ) + assert _create_call_kwargs(conn)["model"] == DEPLOYMENT + + +def test_native_not_applied_when_deployment_model_absent() -> None: + """Native NOT applied when the backing model of the deployment is unknown.""" + conn = _connection() + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" not in _create_call_kwargs(conn) + + +def test_native_not_applied_for_unknown_deployment_model() -> None: + """Native NOT applied for a backing model outside the allowlist.""" + conn = _connection() + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="some-unknown-model", + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" not in _create_call_kwargs(conn) + + +def test_native_not_applied_for_bare_gpt_4o() -> None: + """Native NOT applied for a bare `gpt-4o` backing model. + + Azure carries model name and model version as separate properties, so a bare + `gpt-4o` may be the 2024-05-13 version, which predates structured output support. + """ + conn = _connection() + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o", + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" not in _create_call_kwargs(conn) + + +@pytest.mark.parametrize("api_version", ["2024-08-01", "2024-10-21"]) +def test_native_applied_for_ga_date_at_or_above_floor(api_version: str) -> None: + """Native applied for a bare GA date at or above the floor. + + The documented floor is the preview form `2024-08-01-preview`, so these pin that a + bare GA date carrying no `-preview` suffix is admitted, and that `2024-08-01` is the + inclusive boundary. + """ + conn = _connection(api_version=api_version) + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" in _create_call_kwargs(conn) + + +@pytest.mark.parametrize("api_version", ["v1", "latest"]) +def test_native_not_applied_for_non_date_api_version(api_version: str) -> None: + """Native NOT applied for an api-version outside the documented dated form. + + Every one of these sorts above the floor as a string, so only classifying the + dated form keeps them out. The `v1` literal in particular does not reach Azure's + v1 endpoint from here: `AzureOpenAI` sends it as a query parameter on the + deployment-scoped chat/completions path. + """ + conn = _connection(api_version=api_version) + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" not in _create_call_kwargs(conn) + + +def test_native_not_applied_when_api_version_below_floor() -> None: + """Native NOT applied when the configured api-version predates the floor.""" + conn = _connection(api_version=BELOW_FLOOR_API_VERSION) + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" not in _create_call_kwargs(conn) + + +def test_native_not_applied_when_api_version_empty() -> None: + """Native NOT applied when no api-version is configured. + + The empty string stands in for an absent api-version: the field is required at + construction, so `None` is rejected by validation before chat() is ever reached. + """ + conn = _connection(api_version="") + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" not in _create_call_kwargs(conn) + + +def test_native_not_applied_when_schema_none() -> None: + """Native NOT applied when no output schema is supplied.""" + conn = _connection() + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=None, + ) + assert "response_format" not in _create_call_kwargs(conn) + + +def test_native_not_applied_for_row_type_info() -> None: + """Native NOT applied for a RowTypeInfo schema (BaseModel-only scope).""" + conn = _connection() + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=ROW_TYPE), + ) + assert "response_format" not in _create_call_kwargs(conn) + + +def test_native_applied_even_when_tools_bound() -> None: + """Native applied for a BaseModel even when tools are bound. + + Azure documents structured outputs as unsupported with parallel function calls, + which constrains strict tool schemas rather than the response_format this branch + sets, so binding tools does not gate it. + """ + conn = _connection() + tool = FunctionTool(func=PythonFunction.from_callable(_add)) + conn.chat( + [ChatMessage(role=MessageRole.USER, content="hi")], + tools=[tool], + model=DEPLOYMENT, + model_of_azure_deployment="gpt-4o-mini", + output_schema=OutputSchema(output_schema=Person), + ) + assert "response_format" in _create_call_kwargs(conn) + + +@pytest.mark.parametrize("in_additional_kwargs", [True, False]) +def test_caller_response_format_conflicts_with_native_schema( + in_additional_kwargs: bool, +) -> None: + """A caller-supplied response_format alongside a natively applied schema raises. + + Both values would otherwise reach the same create() call, where the direct kwarg + is silently overwritten and the additional_kwargs one becomes a duplicate keyword + argument reported by the SDK rather than by this connection. + """ + conn = _connection() + with pytest.raises(ValueError, match="response_format") as excinfo: + _chat_with_caller_response_format( + conn, + model_of_azure_deployment="gpt-4o-mini", + in_additional_kwargs=in_additional_kwargs, + ) + assert "Person" in str(excinfo.value) + + +@pytest.mark.parametrize("in_additional_kwargs", [True, False]) +@pytest.mark.parametrize( + ("api_version", "model_of_azure_deployment", "schema"), + [ + (CAPABLE_API_VERSION, "gpt-4o", Person), + (CAPABLE_API_VERSION, "gpt-4o-mini", ROW_TYPE), + (CAPABLE_API_VERSION, "gpt-4o-mini", None), + (BELOW_FLOOR_API_VERSION, "gpt-4o-mini", Person), + ], + ids=[ + "incapable_model", + "row_type_info_schema", + "no_output_schema", + "api_version_below_floor", + ], +) +def test_caller_response_format_survives_when_native_is_skipped( + api_version: str, + model_of_azure_deployment: str, + schema: Any, + in_additional_kwargs: bool, +) -> None: + """The same caller input passes through untouched wherever native output is skipped. + + Native output is skipped for an incapable backing model, for a schema kind outside + the natively translatable set, for no schema at all, and for an api-version below + the floor. Only the branch that actually sends a schema as response_format may + reject the caller's own value, so identical caller code has to keep working along + every one of those paths, including the no-schema path taken by any caller that + drives response_format itself. + """ + conn = _connection(api_version=api_version) + _chat_with_caller_response_format( + conn, + model_of_azure_deployment=model_of_azure_deployment, + in_additional_kwargs=in_additional_kwargs, + schema=schema, + ) + assert _create_call_kwargs(conn)["response_format"] is CALLER_RESPONSE_FORMAT + + +@pytest.mark.parametrize( + "model", + [ + "gpt-5.1", + "gpt-5.1-chat", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "o3-mini", + "o1", + "gpt-4o-mini", + "gpt-4.1", + "gpt-4.1-nano", + "gpt-4.1-mini", + "o4-mini", + "o3", + ], +) +def test_capability_predicate_accepts_capable_models(model: str) -> None: + """The capability predicate accepts every documented capable Azure model name. + + The list is the whole allowlist, so dropping an entry is caught rather than only + narrowing capability silently. + """ + assert _connection().supports_native_structured_output(model) is True + + +@pytest.mark.parametrize( + "model", + [ + "gpt-4o", + "gpt-35-turbo", + "gpt-4", + "gpt-4o-2024-08-06", + "some-unknown-model", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5-pro", + "gpt-5-codex", + "codex-mini", + "o3-pro", + None, + "", + ], +) +def test_capability_predicate_rejects_incapable_models(model: str | None) -> None: + """The capability predicate rejects incapable, Responses-only, and empty names. + + A version-suffixed value such as `gpt-4o-2024-08-06` is an OpenAI snapshot name, + not a name Azure reports as the model behind a deployment. The codex, `gpt-5-pro` + and `o3-pro` names do support structured outputs but are served only on the + Responses API, so they are incapable on the chat completions API this connection + calls. + """ + assert _connection().supports_native_structured_output(model) is False + + +def test_capability_predicate_reads_no_instance_state() -> None: + """The capability predicate is a pure function of its argument. + + The subclass walk that checks connection capabilities calls this on an instance + built with `__new__`, where reading any field raises AttributeError. + """ + uninitialized = AzureOpenAIChatModelConnection.__new__( + AzureOpenAIChatModelConnection + ) + assert uninitialized.supports_native_structured_output("gpt-5") is True diff --git a/python/flink_agents/runtime/skill/skill_manager.py b/python/flink_agents/runtime/skill/skill_manager.py index c5ccf5925..99f6e87af 100644 --- a/python/flink_agents/runtime/skill/skill_manager.py +++ b/python/flink_agents/runtime/skill/skill_manager.py @@ -137,37 +137,24 @@ def get_skill_dir(self, skill_name: str) -> Path | None: repo = self._repos.get(skill_name) return None if repo is None else repo.get_skill_dir(skill_name) - def resolve_resource_path(self, skill_name: str, resource_path: str) -> Path | None: - """Resolve a skill resource's relative path to an absolute filesystem path. - - Returns None if the skill's repository doesn't support path resolution. - """ - repo = self._repos.get(skill_name) - if repo is None: - return None - dir_path = repo.get_skill_dir(skill_name) - if dir_path is None: - return None - resolved = dir_path / resource_path - return resolved if resolved.is_file() else None - def _load_skills(self) -> None: - for spec in self._config.sources: - try: - handler = skill_source_registry.get(spec.scheme) - repo = handler.open(spec.params) - self._opened_repos.append(repo) - except (OSError, ValueError) as e: - # Release repos opened by earlier iterations — the caller never - # receives a SkillManager reference to clean them up via close() - # itself, so without this their temp dirs / atexit handlers leak - # until interpreter exit. - self.close() - msg = ( - f"Failed to load skills from {spec.scheme}:{spec.params}" - ) - raise RuntimeError(msg) from e - self._register_repo(repo, _origin_of(spec)) + try: + for spec in self._config.sources: + try: + handler = skill_source_registry.get(spec.scheme) + repo = handler.open(spec.params) + self._opened_repos.append(repo) + except (OSError, ValueError) as e: + msg = f"Failed to load skills from {spec.scheme}:{spec.params}" + raise RuntimeError(msg) from e + self._register_repo(repo, _origin_of(spec)) + except BaseException: + # Release every repo opened so far — the caller never receives a + # SkillManager reference to clean them up via close() itself, so + # without this their temp dirs / atexit handlers leak until + # interpreter exit. + self.close() + raise def _register_repo(self, repo: "SkillRepository", origin: SkillOrigin) -> None: for skill in repo.get_skills(): diff --git a/python/flink_agents/runtime/skill/skill_repository.py b/python/flink_agents/runtime/skill/skill_repository.py index 5cca1f8b0..1b76afe6f 100644 --- a/python/flink_agents/runtime/skill/skill_repository.py +++ b/python/flink_agents/runtime/skill/skill_repository.py @@ -16,32 +16,12 @@ # limitations under the License. ################################################################################# from abc import ABC, abstractmethod -from dataclasses import dataclass from pathlib import Path from typing import Dict, List from flink_agents.runtime.skill.agent_skill import AgentSkill -@dataclass -class SkillRepositoryInfo: - """Information about a skill repository. - - Attributes: - ---------- - repo_type : str - The type of repository (e.g., "filesystem", "classpath", "url"). - location : str - The location of the repository (e.g., path, URL). - writeable : bool - Whether the repository supports write operations. - """ - - repo_type: str - location: str - writeable: bool - - class SkillRepository(ABC): """Source of skills, loaded from filesystem / classpath / URL / package. diff --git a/python/flink_agents/runtime/skill/tests/test_manager.py b/python/flink_agents/runtime/skill/tests/test_manager.py index 4944f846b..5d4c062c3 100644 --- a/python/flink_agents/runtime/skill/tests/test_manager.py +++ b/python/flink_agents/runtime/skill/tests/test_manager.py @@ -357,3 +357,107 @@ def opener(params) -> SkillRepository: assert closed == ["skill-1"], ( "the repo opened before the partial-load failure must be closed" ) + + def test_load_failure_outside_wrapped_types_closes_repos_and_propagates( + self, + ) -> None: + # Contract: a source failure that is neither OSError nor ValueError still + # closes the repos opened before it, and reaches the caller unwrapped. + from typing import Dict, List + + from flink_agents.runtime.skill import skill_source_registry + from flink_agents.runtime.skill.agent_skill import AgentSkill + from flink_agents.runtime.skill.skill_repository import SkillRepository + + closed: List[str] = [] + + class FakeRepo(SkillRepository): + def get_skill(self, name: str) -> AgentSkill | None: + return self.get_skills()[0] if name == "skill-1" else None + + def get_skills(self) -> List[AgentSkill]: + return [AgentSkill(name="skill-1", description="dummy", content="body")] + + def get_resources(self, name: str) -> Dict[str, str]: + return {} + + def close(self) -> None: + closed.append("skill-1") + + counter = {"n": 0} + + def opener(params) -> SkillRepository: + counter["n"] += 1 + if counter["n"] == 2: + msg = "corrupt archive" + raise zipfile.BadZipFile(msg) + return FakeRepo() + + skill_source_registry.register("test-badzip-close", opener) + + config = Skills( + sources=[ + SkillSourceSpec(scheme="test-badzip-close", params={}), + SkillSourceSpec(scheme="test-badzip-close", params={}), + ] + ) + + with pytest.raises(zipfile.BadZipFile): + SkillManager(config) + assert closed == ["skill-1"], ( + "a load failure outside (OSError, ValueError) must still close the " + "repos opened before it" + ) + + def test_registration_failure_closes_earlier_repo_and_propagates(self) -> None: + # Contract: a failure raised while registering a repo — after its open() + # already succeeded — still closes the repo from an earlier source. + from typing import Dict, List + + from flink_agents.runtime.skill import skill_source_registry + from flink_agents.runtime.skill.agent_skill import AgentSkill + from flink_agents.runtime.skill.skill_repository import SkillRepository + + closed: List[str] = [] + + class FakeRepo(SkillRepository): + def __init__(self, tag: str, *, boom: bool) -> None: + self._tag = tag + self._boom = boom + + def get_skill(self, name: str) -> AgentSkill | None: + return None + + def get_skills(self) -> List[AgentSkill]: + if self._boom: + msg = "exploding during registration" + raise KeyError(msg) + return [AgentSkill(name=self._tag, description="d", content="b")] + + def get_resources(self, name: str) -> Dict[str, str]: + return {} + + def close(self) -> None: + closed.append(self._tag) + + counter = {"n": 0} + + def opener(params) -> SkillRepository: + counter["n"] += 1 + return FakeRepo(f"skill-{counter['n']}", boom=counter["n"] == 2) + + skill_source_registry.register("test-register-boom", opener) + + config = Skills( + sources=[ + SkillSourceSpec(scheme="test-register-boom", params={}), + SkillSourceSpec(scheme="test-register-boom", params={}), + ] + ) + + with pytest.raises(KeyError): + SkillManager(config) + assert closed == ["skill-1", "skill-2"], ( + "a registration failure must close every repo opened so far — the " + "earlier source's repo and the one whose registration failed" + ) diff --git a/review-guides/python-java-bridge.md b/review-guides/python-java-bridge.md new file mode 100644 index 000000000..685a19888 --- /dev/null +++ b/review-guides/python-java-bridge.md @@ -0,0 +1,54 @@ +# Review Guide: Python-Java Bridge + +Load this guide when a PR changes code that crosses the Python-Java boundary: +Pemja entry points, resource or tool wrappers, event and agent-plan +serialization, or type conversion in either direction. It narrows the full +passes in `code_review.md` to the ones that matter most for this area; the +general passes still apply. + +## Focused checklist + +- When a method lands on a type that exists in both languages, check every + wrapper carrying it across, not only the two implementations. A wrapper + inheriting the other side's implementation can fail the call instead of + crossing it, and one left on the legacy path degrades silently. +- Settle what an explicitly null declarative argument means. A Java descriptor + lookup cannot distinguish an absent argument from one declared null, while + Python can, so the same YAML can reach a different conclusion on each side. +- Keep constants shared by both languages in sync: event type strings, resource + types, YAML aliases, flattened-map keys. A new event type or attribute also + needs the cross-language snapshots regenerated and committed on both sides. +- Confirm both legs of a conversion carry the same fields. An argument present + on one leg and missing on the other drops data with no error, and the legs + usually live in different files. +- Treat bridge entry-point names as a contract. Python function names called + from Java and Java fully-qualified names resolved from Python are string + literals, so renaming or moving either breaks only at runtime. +- Keep values that cross the boundary flattened to primitives, strings, lists, + and maps. Returning an arbitrary object in either direction to a call that + originated on a non-main interpreter thread can crash the JVM, which is why + the existing conversions return flat maps. + +## Validation + +Run both language lanes. A bridge change verified on one side only is untested. + +- Java: `mvn --batch-mode test -pl runtime -am`. The `-am` matters here because + the Java halves of the cross-language snapshot tests live in `api` and + `plan`, upstream of the module that owns the bridge implementations. +- Python: from `python/`, run `uv sync --extra test`, install the + `apache-flink` release for the Flink version under test (`tools/ut.sh` names + the supported versions), then `uv run --no-sync pytest flink_agents/runtime + flink_agents/api flink_agents/plan`. PyFlink is not a declared test + dependency and the event types import it, so collection fails without it. + +Together these run the committed cross-language snapshot tests from both sides. +Dispatch through a real interpreter is only covered by the cross-language +end-to-end modules. + +## Examples from past reviews + +| Case | Pass it exercises | Review | +|---|---|---| +| A usage-tracking method reached both setup types but not the wrappers, so each inherited an implementation its connection cannot serve: the Python-backed setup never initializes the Java connection, the Java-backed one holds only a resource name. Calls failed instead of crossing, and the connection wrappers fell back to the legacy call, returning no usage. | Whether every wrapper carrying a call across the boundary was updated, including result conversion and cross-language tests. | [#870](https://github.com/apache/flink-agents/pull/870#discussion_r3592370999) | +| An explicitly configured null `structured_output_strategy` was normalized to `AUTO` on the Java side, while Python rejected `None` with a validation error. | Comparing the behavior each language derives from the same declarative value. | [#843](https://github.com/apache/flink-agents/pull/843#discussion_r3637512045) | diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java index 4c0eae6c0..9111edf6b 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/skill/SkillManager.java @@ -26,7 +26,6 @@ import javax.annotation.Nullable; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; @@ -159,48 +158,38 @@ public Path getSkillDir(String skillName) { return repo == null ? null : repo.getSkillDir(skillName); } - /** Resolve a skill resource's relative path to an absolute path, or {@code null} if missing. */ - @Nullable - public Path resolveResourcePath(String skillName, String resourcePath) { - SkillRepository repo = repos.get(skillName); - if (repo == null) { - return null; - } - Path dir = repo.getSkillDir(skillName); - if (dir == null) { - return null; - } - Path resolved = dir.resolve(resourcePath); - return Files.isRegularFile(resolved) ? resolved : null; - } - private void loadAll() { - for (SkillSourceSpec spec : config.getSources()) { - try { - SkillRepository repo = - SkillSourceRegistry.get(spec.getScheme()) - .open(spec.getParams(), classLoader); - openedRepos.add(repo); - registerRepo(repo, originOf(spec)); - } catch (IOException | IllegalArgumentException e) { - IllegalStateException toThrow = - new IllegalStateException( - "Failed to load skills from " - + spec.getScheme() - + ":" - + spec.getParams(), - e); - // Release repos registered before this point. The caller never receives a - // SkillManager reference (we're throwing from the constructor path), so - // without this cleanup their shutdown hooks + temp dirs would leak until - // JVM exit. + try { + for (SkillSourceSpec spec : config.getSources()) { try { - closeRepos(); - } catch (Exception cleanupError) { - toThrow.addSuppressed(cleanupError); + SkillRepository repo = + SkillSourceRegistry.get(spec.getScheme()) + .open(spec.getParams(), classLoader); + openedRepos.add(repo); + registerRepo(repo, originOf(spec)); + } catch (IOException | IllegalArgumentException e) { + throw new IllegalStateException( + "Failed to load skills from " + + spec.getScheme() + + ":" + + spec.getParams(), + e); } - throw toThrow; } + } catch (Throwable t) { + // Release every repo opened so far, on any failure path. The caller never + // receives a SkillManager reference (we're throwing from the constructor + // path), so without this cleanup their shutdown hooks + temp dirs would leak + // until JVM exit. The original failure propagates unchanged; a cleanup + // failure rides along as suppressed so neither is lost — including an Error, + // which closeRepos() does not catch per-repo and which would otherwise + // replace the original failure outright. + try { + closeRepos(); + } catch (Throwable cleanupError) { + t.addSuppressed(cleanupError); + } + throw t; } } diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java index dfc05f359..daf8660f1 100644 --- a/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/skill/SkillManagerTest.java @@ -106,14 +106,6 @@ void getSkillDirsThrowsForUnknownName() { assertTrue(ex.getMessage().contains("github")); } - @Test - void resolveResourcePathLocatesBundledFile() { - SkillManager manager = new SkillManager(configFromResources()); - Path resolved = manager.resolveResourcePath("nano-banana-pro", "scripts/generate_image.py"); - assertNotNull(resolved); - assertTrue(Files.isRegularFile(resolved)); - } - private static void zipDir(Path src, Path dstZip) throws IOException { try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(dstZip)); Stream walk = Files.walk(src)) { @@ -299,23 +291,35 @@ void mixedSourcesAllBranchesExecute(@TempDir Path tempDir) throws IOException { /** * Minimal {@link SkillRepository} for lifecycle tests: each instance owns one fake skill and - * records whether {@code close()} was invoked. May be configured to throw a {@link - * RuntimeException} on close ({@link SkillRepository#close()} declares no checked exceptions; - * {@link SkillManager#close()} catches {@code Exception} so this still exercises the cascade - * logic). + * records whether {@code close()} was invoked. May be configured to fail on close, exercising + * the cascade logic in {@link SkillManager#close()}, and/or to fail from {@link #getSkills()}, + * which fails the repo's registration after its {@code open()} has already succeeded. + * + *

{@link SkillRepository} declares no checked exceptions, so a configured failure is either + * a {@link RuntimeException} or an {@link Error}. Both kinds are needed: they take different + * paths through the handlers in {@link SkillManager}. */ private static final class FakeRepo implements SkillRepository { private final AgentSkill skill; final AtomicBoolean closed = new AtomicBoolean(); - @javax.annotation.Nullable private final RuntimeException closeException; + @javax.annotation.Nullable private final Throwable closeException; + @javax.annotation.Nullable private final Throwable getSkillsException; FakeRepo(String skillName) { this(skillName, null); } - FakeRepo(String skillName, @javax.annotation.Nullable RuntimeException closeException) { + FakeRepo(String skillName, @javax.annotation.Nullable Throwable closeException) { + this(skillName, closeException, null); + } + + FakeRepo( + String skillName, + @javax.annotation.Nullable Throwable closeException, + @javax.annotation.Nullable Throwable getSkillsException) { this.skill = new AgentSkill(skillName, "fake", "body", null, null, null); this.closeException = closeException; + this.getSkillsException = getSkillsException; } @Override @@ -325,6 +329,9 @@ public AgentSkill getSkill(String name) { @Override public List getSkills() { + if (getSkillsException != null) { + throwUnchecked(getSkillsException); + } return List.of(skill); } @@ -337,9 +344,25 @@ public Map getResources(String name) { public void close() { closed.set(true); if (closeException != null) { - throw closeException; + throwUnchecked(closeException); } } + + /** + * Throw a configured failure. Declaring the fields as {@link Throwable} lets one field + * carry either kind of unchecked failure; the interface permits nothing else, so a checked + * exception is a test-setup mistake and fails loudly rather than being smuggled past the + * compiler. + */ + private static void throwUnchecked(Throwable failure) { + if (failure instanceof Error) { + throw (Error) failure; + } + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; + } + throw new AssertionError("FakeRepo failures must be unchecked", failure); + } } @Test @@ -394,6 +417,60 @@ void constructorFailureSurfacesCleanupErrorAsSuppressed() { assertSame(cleanupBoom, ex.getSuppressed()[0]); } + @Test + void registrationFailureOfUnwrappedTypeClosesReposAndPropagates() { + // A failure whose type is neither IOException nor IllegalArgumentException reaches the + // caller unchanged rather than being wrapped, so only a guard spanning every failure path + // can release the repos. Two repos are owned by the time registration fails: the earlier + // source's, and the one whose registration failed (it is recorded before registration + // runs). Both must be released, and a failure during that release must ride along as + // suppressed instead of replacing the original. + IllegalStateException registrationBoom = new IllegalStateException("registration-boom"); + RuntimeException cleanupBoom = new RuntimeException("cleanup-boom"); + FakeRepo first = new FakeRepo("alpha", cleanupBoom); + FakeRepo failing = new FakeRepo("beta", null, registrationBoom); + SkillSourceRegistry.register("test-register-boom-ok", (params, cl) -> first); + SkillSourceRegistry.register("test-register-boom-fail", (params, cl) -> failing); + + Skills config = + new Skills( + List.of( + new SkillSourceSpec("test-register-boom-ok", Map.of()), + new SkillSourceSpec("test-register-boom-fail", Map.of()))); + + IllegalStateException ex = + assertThrows(IllegalStateException.class, () -> new SkillManager(config)); + // Identity, not just type: IllegalStateException is also what the wrapping catch builds. + assertSame(registrationBoom, ex); + assertTrue(first.closed.get(), "repo opened before the failure must be closed"); + assertTrue(failing.closed.get(), "repo whose registration failed must be closed"); + assertEquals(1, ex.getSuppressed().length); + assertSame(cleanupBoom, ex.getSuppressed()[0]); + } + + @Test + void errorDuringRegistrationStillClosesRepoAndSuppressesCloseError() { + // An Error is not an Exception, so it survives a load failure only if both the guard around + // the source loop and the guard around that guard's cleanup accept Throwable. This repo + // fails its registration with one Error and then its close() with another: a load guard + // narrowed to Exception would skip the cleanup entirely, and a cleanup guard narrowed to + // Exception would let the close() Error escape and replace the registration failure. + // One source keeps the assertions deterministic — closeRepos() catches only Exception per + // repo, so an Error from any repo's close() ends the iteration over the remaining ones. + Error registrationBoom = new Error("registration-error"); + Error closeBoom = new Error("close-error"); + FakeRepo repo = new FakeRepo("alpha", closeBoom, registrationBoom); + SkillSourceRegistry.register("test-error-fail", (params, cl) -> repo); + + Skills config = new Skills(List.of(new SkillSourceSpec("test-error-fail", Map.of()))); + + Error ex = assertThrows(Error.class, () -> new SkillManager(config)); + assertSame(registrationBoom, ex); + assertTrue(repo.closed.get(), "the repo owned when registration failed must be closed"); + assertEquals(1, ex.getSuppressed().length); + assertSame(closeBoom, ex.getSuppressed()[0]); + } + @Test void closeAttemptsEveryRepoAndRethrowsFirstFailure() throws Exception { // Three repos: middle one throws on close. SkillManager.close() must (a) attempt all diff --git a/tools/.rat-excludes b/tools/.rat-excludes index 94dacb5f4..6aae307f8 100644 --- a/tools/.rat-excludes +++ b/tools/.rat-excludes @@ -22,6 +22,7 @@ PULL_REQUEST_TEMPLATE.md .*\.egg-info/* licenses/* skills/* +flink-agents-dev .*\.yaml$ AGENTS.md code_review.md