feat: Add structured output (constrained decoding) support for agentic memory fact extraction - #4824
Conversation
PR Code Analyzer ❗AI-powered 'Code-Diff-Analyzer' found issues on commit ef45b98.
The table above displays the top 10 most important findings. Pull Requests Author(s): Please update your Pull Request according to the report above. Repository Maintainer(s): You can Thanks. |
PR Reviewer Guide 🔍(Review updated until commit 96c6ff8)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to ef45b98 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 96c6ff8
Suggestions up to commit 28b697b
Suggestions up to commit f3621f5
Suggestions up to commit e999b8d
Suggestions up to commit 559c9ad
|
Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
4eac4a3 to
89b5488
Compare
|
Persistent review updated to latest commit 89b5488 |
Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
|
Persistent review updated to latest commit acb2315 |
Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
…nsearch-project#2 Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
|
Persistent review updated to latest commit 96c6ff8 |
Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
ylwu-amzn
left a comment
There was a problem hiding this comment.
No blocking issues. Approving to catch the 3.7 release; please file a follow-up PR for the inline feedback.
| case SUPPORTS_STRUCTURED_OUTPUT_FIELD: | ||
| supportsStructuredOutput = parser.booleanValue(); | ||
| break; | ||
| default: |
There was a problem hiding this comment.
High-priority — downgrade BWC: the new field is silently dropped on XContent parse by 3.6 nodes.
The wire-stream BWC at line 138 is correctly gated on VERSION_3_7_0 and tested. But connectors are also persisted as JSON to .plugins-ml-connector and re-parsed on read. After a 3.7 → 3.6 rolling downgrade, a 3.6 node reading a connector doc with "supports_structured_output": true falls through this default: parser.skipChildren() branch and silently drops the flag — structured output stops working with no log line.
Not a blocker (downgrades are the cluster admin's responsibility), but worth:
- A release-note line in
release-notes/opensearch-ml-commons.release-notes-3.7.0.0.mdcalling out that downgrade clearssupports_structured_output. - Optionally
log.debug("Skipping unknown connector_action field: {}", fieldName)on the default case so this is at least visible in logs.
For reference: this is purely an XContent (index) BWC concern — the wire-stream path at lines 134-139 is fine.
| if (hostHasSegment(host, URL_TOKEN_OPENAI) | ||
| || pathHasSegment(path, URL_TOKEN_OPENAI) | ||
| || hostHasSegment(host, URL_TOKEN_DEEPSEEK) | ||
| || path.contains(URL_TOKEN_OPENAI_COMPAT_PATH)) { | ||
| return Map.of("_response_format_json", FACTS_EXTRACTION_OPENAI_RESPONSE_FORMAT_JSON); |
There was a problem hiding this comment.
High-priority — schema-leak via misconfigured connector URL (defense-in-depth).
This routes supports_structured_output: true connectors to the OpenAI schema based on token-equality matching. An admin who sets the flag on a connector pointing at https://openai.attacker.com/v1/chat/completions (or any URL where the host contains the literal label openai, or the path contains the literal segment /v1/chat/completions) will inject the OpenAI schema into the outbound request body.
The inline comment above correctly notes the threat model ("supports_structured_output must be explicitly set to true... requires admin access, so an unintended match only results in an extra response_format field being sent"). The schema doesn't contain secrets, so this isn't a credential-exposure issue. But it's still a small information disclosure to whatever endpoint the connector points at.
Cheap defense-in-depth: cross-reference the resolved URL against plugins.ml_commons.trusted_connector_endpoints_regex before deriving the provider. If an admin sets the flag on a connector pointing at a non-allowlisted host, fail fast at predict time rather than silently leak. This dovetails with the work in PR #4826.
Alternatively, tighten matching to full-host equality for the public providers: host.equals("api.openai.com") etc. That removes the openai.attacker.com class of false matches entirely.
Not blocking since both connector creation and the flag are admin-only.
| JsonObject target = body.has(fieldName) && body.get(fieldName).isJsonObject() | ||
| ? body.getAsJsonObject(fieldName) | ||
| : new JsonObject(); | ||
| additions.entrySet().forEach(e -> target.add(e.getKey(), e.getValue())); |
There was a problem hiding this comment.
High-priority — _<field>_additions_json "merge" overwrites existing keys; the JavaDoc says "merge into" which is ambiguous.
JsonObject.add(key, value) replaces when the key already exists. So if a connector author has generationConfig: {"temperature": 0.7, "maxOutputTokens": 1024} in the request body template, and the additions are {"temperature": 0.99, "responseMimeType": "application/json"}, the user's temperature: 0.7 is silently overridden by 0.99.
The current FACTS_EXTRACTION_GEMINI_GENERATION_CONFIG_JSON constant doesn't include any keys that overlap with what a sensible connector author would pre-populate, so it's not a problem today. But future schemas could collide silently.
Two options:
- Update the JavaDoc on lines 403-409 to be explicit: "On key collision, addition values replace existing values in the target object — the operation is not strictly additive." This is the lowest-friction option.
- Change semantics to genuinely additive (
if (!target.has(key)) target.add(key, value)) and force schema authors to use_<field>_json(full replace) when they need overrides. More disruptive but truer to the name_additions_.
A test like createPayload_AdditionsJson_DoesNotOverrideExistingKey would also pin the chosen behavior.
| } | ||
| // Cohere structured output (json_schema type) requires the v2 Chat API (/v2/chat). | ||
| // The legacy /v1/chat endpoint only supports json_object and ignores json_schema. | ||
| if (hostHasSegment(host, URL_TOKEN_COHERE) && pathHasSegment(path, URL_TOKEN_V2_PATH_SEGMENT)) { |
There was a problem hiding this comment.
Cohere v2 path matching accepts more URLs than just chat. pathHasSegment(path, URL_TOKEN_V2_PATH_SEGMENT) matches any URL with a /v2/ segment, so https://api.cohere.com/v2/embed, /v2/rerank, etc., all classify as "Cohere v2 chat" and receive a response_format field that those endpoints don't understand. Cohere will likely 400, surfacing as a confusing fact-extraction failure for users who configured a non-chat Cohere connector with supports_structured_output: true.
Fix: tighten the path check to require both v2 and chat segments, or check for the literal substring /v2/chat (similar to how URL_TOKEN_OPENAI_COMPAT_PATH is used for the OpenAI-compatible endpoints below).
|
|
||
| // Pass 1: _<field>_json — inject or replace a top-level field | ||
| for (Map.Entry<String, String> entry : parameters.entrySet()) { | ||
| if (entry.getKey().endsWith("_additions_json")) |
There was a problem hiding this comment.
Defense-in-depth, but fragile: this endsWith("_additions_json") skip is what prevents Pass 1 from also processing _<field>_additions_json keys. Without it, Pass 1 would call allowedFieldName("_response_format_additions_json", "_json") which slices to "response_format_additions" — not in the allowlist, so it returns null and gets skipped anyway. So the skip is redundant today given the allowlist, but if someone removes this line during a refactor (thinking it's redundant) and also extends the allowlist later (e.g., adds "response_format_additions" for some reason), the bug surface widens silently.
Suggest either:
- A short comment explaining the layering:
// Without this skip, allowlist still prevents Pass 1 from processing *_additions_json keys, but explicit is safer. - Or refactor the loop to classify each key once into one of {replace, merge, ignore} so the dispatch is structural rather than implicit.
| static final Set<String> STRUCTURED_OUTPUT_ALLOWED_FIELDS = Set | ||
| .of( | ||
| "response_format", // OpenAI, Azure OpenAI, DeepSeek, Ollama, Cohere | ||
| "generationConfig", // Google Gemini | ||
| "toolConfig" // Amazon Bedrock Converse | ||
| ); |
There was a problem hiding this comment.
The 3-element allowlist here is the wire-layer enforcement. The corresponding constants in MemoryContainerConstants.FACTS_EXTRACTION_* (OPENAI, COHERE, GEMINI, BEDROCK_CONVERSE) are tightly coupled to these field names — adding a new provider requires extending both files in lockstep, otherwise the schema constant exists but the wire layer silently drops the injection.
Cheap fix: add a one-liner cross-reference here, e.g.:
// Keep in sync with MemoryContainerConstants.FACTS_EXTRACTION_*: each constant's top-level
// field name must appear in this allowlist to actually reach the outgoing request body.…and a similar pointer in MemoryContainerConstants near the schema constants.
| } | ||
| } | ||
| if (parameters != null && isJson(payload) && connectorAction.get().isSupportsStructuredOutput()) { | ||
| payload = injectStructuredOutputParams(parameters, payload); |
There was a problem hiding this comment.
Confirmed gate is correct: connectorAction.get().isSupportsStructuredOutput() is the admin-only flag, so a non-admin user cannot bypass the allowlist by passing _response_format_json directly in _predict. The only way the injection runs is if an admin set the flag on the action at create/update time. Worth a unit test that verifies a connector with the flag set to false ignores _response_format_json even when present in parameters — couldn't find that test in the new HttpConnectorTest cases. It's the inverse of _DefaultsFalse and explicit about the gate.
| if (connector.getActions() == null) { | ||
| return Map.of(); | ||
| } | ||
| // first matching PREDICT action wins; assumes one PREDICT per connector for fact extraction |
There was a problem hiding this comment.
Nit: the comment correctly notes "first matching PREDICT action wins; assumes one PREDICT per connector for fact extraction." If a connector has multiple named PREDICT actions (per the action-name framework that landed in 3.5.0), only the first one with supports_structured_output: true is considered. That's fine for agentic memory's current single-action assumption. Worth converting the comment into a // TODO: if multi-action structured output is on the roadmap, so it's grep-able.
| // Captures (1) host and (2) path from a URL, stopping before '?' or '#'. | ||
| // Handles connector template URLs such as https://${parameters.endpoint}/openai/... | ||
| // where the host may be a placeholder — in that case path matching takes over. | ||
| private static final Pattern URL_PARTS = Pattern.compile("^[^:]+://([^/?#]*)(/?[^?#]*)"); |
There was a problem hiding this comment.
Edge case: this regex captures the entire URL authority into group 1, including userinfo. So https://user:openai@attacker.example.com/path gives host = "user:openai@attacker.example.com". After host.split("\\.", -1), you get ["user:openai@attacker", "example", "com"] — none of those equals the bare token "openai", so the current implementation is safe. But this isn't tested anywhere I could find. Worth one targeted test (testGetStructuredOutputParameters_UrlWithUserinfo_ReturnsEmptyMap or similar) to pin the behavior so a future regex change doesn't accidentally introduce a host-confusion bug.
- High-priority — downgrade BWC: the new field is silently dropped on XContent parse by 3.6 nodes.
- Reminder comments to keep all of these schema constant variables defined in lockstep (URL predicate, injection-parameter name, schema constant, allowlisted field)
- Test that verifies a connector with the flag set to false ignores _response_format_json even when present in parameters
- Addresses: _<field>_additions_json "merge" overwrites existing keys; the JavaDoc says "merge into" which is ambiguous. Update test createPayload_AdditionsJson_DoesNotOverrideExistingKey
- Update testGetStructuredOutputParameters_ProviderTokenInUserinfo_ReturnsEmptyMap to test that URL_PARTS regex excludes userInfo for security
- Add TODO to update schemaForConnector return if multi-action connectors is implemented
- Tighten schemaForUrl cohere path check to require both v2 and chat segments, or check for the literal substring /v2/chat
- Tighten schemaForUrl openai host matching to: host.equals("api.openai.com") for security
Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
- High-priority — downgrade BWC: the new field is silently dropped on XContent parse by 3.6 nodes.
- Reminder comments to keep all of these schema constant variables defined in lockstep (URL predicate, injection-parameter name, schema constant, allowlisted field)
- Test that verifies a connector with the flag set to false ignores _response_format_json even when present in parameters
- Addresses: _<field>_additions_json "merge" overwrites existing keys; the JavaDoc says "merge into" which is ambiguous. Update test createPayload_AdditionsJson_DoesNotOverrideExistingKey
- Update testGetStructuredOutputParameters_ProviderTokenInUserinfo_ReturnsEmptyMap to test that URL_PARTS regex excludes userInfo for security
- Add TODO to update schemaForConnector return if multi-action connectors is implemented
- Tighten schemaForUrl cohere path check to require both v2 and chat segments, or check for the literal substring /v2/chat
- Tighten schemaForUrl openai host matching to: host.equals("api.openai.com") for security
Signed-off-by: Emerson Havener <emersonhavener@gmail.com>
Description
Adds structured output (constrained decoding) support for agentic memory fact extraction. When a connector's predict action has
supports_structured_output: true,MemoryContainerHelperresolves the provider from the connector URL and injects a provider-specific JSON schema into fact-extraction requests.HttpConnectorreads_*_json/_*_additions_jsonparameters and injects them as top-level fields in the outgoing request body, so the provider enforces output structure at the token level rather than relying on prompt instructions.Implemented providers: OpenAI, Azure OpenAI, DeepSeek, Ollama, Cohere v2, Google Gemini/Vertex AI, Amazon Bedrock Converse
How it works
ConnectorAction— adds asupportsStructuredOutputboolean field (defaultfalse); wire-serialized for clusters ≥ 3.7.0 only.HttpConnector.injectStructuredOutputParams— reads_<X>_json/_<X>_additions_jsonparameters and injects them as top-level fields in the outgoing request body.MemoryContainerHelper.getStructuredOutputParameters— async lookup that resolves the model's connector, checks the flag, and returns the provider-specific schema parameters. Returns empty map on any failure; callers fall back to prompt enforcement.MemoryProcessingService— merges structured output params into the predict request when available, skipping the prompt enforcement sentence. Falls back gracefully on lookup failure.Manual testing
Ollama : A smoke test via a direct _predict call with _response_format_json confirmed that HttpConnector.injectStructuredOutputParams correctly reads the _*_json naming convention and injects response_format into the Ollama request body. Ollama then enforces the schema at the token level, returning {"facts": ["The user prefers dark mode."]} instead of free-forming the structure.
Bedrock Converse (claude-sonnet-4-6)
Related Issues
Resolves #4799
Check List
--signoff.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.