Skip to content

feat(responses): unify tool dispatch under a single agentic-loop owner (#1046) - #1133

Merged
leseb merged 4 commits into
praxis-proxy:mainfrom
leseb:leseb/issue-1046
Sep 14, 2026
Merged

leseb merged 4 commits into
praxis-proxy:mainfrom
leseb:leseb/issue-1046

Conversation

@leseb

@leseb leseb commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes openai_agentic_loop the single owner and sole parser of the Responses agentic loop, demoting openai_web_search, openai_mcp_dispatch, and openai_file_search_callout to execution-only dispatchers with no response phase, so one filter normalizes/accumulates/classifies every output item, publishes the single loop signal, and runs the one fallible terminal-buffered finalizer. File-search calls are carried by index (FileSearchAssignment) into a shared accumulated_output rather than copied, DispatchFailure is converted by the owner into a buffered JSON rejection or streaming SSE error frame, and a new mcp_classify module is the single source of truth for MCP disposition/approval while approval.rs keeps only the durable round-trip. MCP tool calls are exempted from max_tool_calls (built-ins only) while retaining their own per-round/concurrency caps, and mixed server/client function-call ownership fails closed with 502 before any external side effect.

Related issue

Refs #1046

Validation

  • Unit tests — cargo test -p praxis-ai-apis (3362 passed, 0 failed)
  • Integration or functional tests — updated openai_agentic_loop.rs, file_search_callout.rs, test_openai_responses_vllm.py
  • make lint — clippy -D warnings + nightly fmt --check clean; make build clean

Checklist

  • I reviewed every changed line and can explain the change.
  • New capabilities include an example config and functional example test.
  • User-facing behavior and generated documentation are updated.
  • Performance-sensitive changes include appropriate benchmark or load-test evidence.
  • Commits are signed and include a Signed-off-by trailer.

Breaking changes

Example config for the agentic loop is restructured (agentic-loop.yaml and the file-search example configs) to reflect the single-owner pipeline ordering; deployments that copied the prior per-tool response-phase wiring must adopt the owner-driven layout.

praxis-proxy#1046)

Make openai_agentic_loop the single owner and sole parser for the
Responses agentic loop, demoting web_search, mcp_dispatch, and
file_search to execution-only dispatchers with no response phase. The
owner normalizes, accumulates, and classifies every output item into a
shared accumulated_output, decides the single loop signal, and runs the
one fallible finalizer (terminal- and buffered-only), leaving the
streaming terminal frame to openai_stream_events.

Add a dedicated mcp_classify module as the single source of truth for
MCP disposition and approval policy; mcp_dispatch/approval.rs keeps only
the durable approval round-trip (praxis-proxy#1029). File-search calls are carried
by index (FileSearchAssignment) into accumulated_output rather than a
second payload copy, and dispatchers mutate their assigned slots in
place. DispatchFailure is converted by the owner into a buffered JSON
rejection or a streaming SSE error frame.

Per the OpenAI Responses contract, exempt MCP tool calls from
max_tool_calls (built-in tools only) while retaining MCP's independent
per-round and concurrency caps. Fail closed with 502 on mixed
server/client function-call ownership before any external side effect.

Refs praxis-proxy#1046

Signed-off-by: Sébastien Han <seb@redhat.com>
@leseb
leseb requested review from a team and alexsnaps September 11, 2026 17:46
Migrate web-search-chat-completions.yaml on_result to key on
openai_agentic_loop instead of the now execution-only openai_web_search
dispatcher, so the search-then-model continuation loops under the single
owner (issue praxis-proxy#1046). Regenerate apis/src/openai/responses/README.md to
drop the response-phase hooks from the demoted web_search and
mcp_dispatch dispatchers.

Refs praxis-proxy#1046

Signed-off-by: Sébastien Han <seb@redhat.com>

@franciscojavierarceo franciscojavierarceo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streamed local completions can return an empty 200 SSE body.

on every streaming IRR re-entry, openai_stream_events::arm clears ResponsesState.response_object at apis/src/openai/responses/stream_events/mod.rs:164-174. when a max_tool_calls rejection or approval-only round then completes before another upstream response, finish_deferred_local_response calls encode_local_completion at apis/src/openai/responses/agentic_loop/mod.rs:407-426. encode_local_completion returns None whenever that response object is no longer an object at apis/src/openai/responses/stream_events/mod.rs:1333-1343.

the client receives a 200 text/event-stream response without response.completed (and, when applicable, [DONE]), so a valid streamed approval or tool-limit response cannot complete reliably. we need to preserve a terminal response template or build one from the stable response id and accumulated output, and add a regression that exercises approval-only and tool-limit termination after a streaming IRR re-entry.

Signed-off-by: Sébastien Han <seb@redhat.com>
Retain the previous streamed response template across IRR request-side re-entry so approval-only and tool-limit exits can emit a canonical response.completed event before another upstream response exists. Drop the fallback when upstream response headers arrive and include it in continuation-state accounting.

Signed-off-by: Sébastien Han <seb@redhat.com>
@leseb

leseb commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

streamed local completions can return an empty 200 SSE body.

on every streaming IRR re-entry, openai_stream_events::arm clears ResponsesState.response_object at apis/src/openai/responses/stream_events/mod.rs:164-174. when a max_tool_calls rejection or approval-only round then completes before another upstream response, finish_deferred_local_response calls encode_local_completion at apis/src/openai/responses/agentic_loop/mod.rs:407-426. encode_local_completion returns None whenever that response object is no longer an object at apis/src/openai/responses/stream_events/mod.rs:1333-1343.

the client receives a 200 text/event-stream response without response.completed (and, when applicable, [DONE]), so a valid streamed approval or tool-limit response cannot complete reliably. we need to preserve a terminal response template or build one from the stable response id and accumulated output, and add a regression that exercises approval-only and tool-limit termination after a streaming IRR re-entry.

Fixed in 6395539. Thanks!

@franciscojavierarceo franciscojavierarceo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed the current head, the preserved local streaming completions, agentic-loop ownership, targeted tests, and existing comments. no blocking issues found.

@leseb
leseb added this pull request to the merge queue Sep 14, 2026
@leseb
leseb removed this pull request from the merge queue due to a manual request Sep 14, 2026
@leseb
leseb merged commit f104dc6 into praxis-proxy:main Sep 14, 2026
28 checks passed
@leseb
leseb deleted the leseb/issue-1046 branch September 14, 2026 09:36
@leseb leseb linked an issue Sep 14, 2026 that may be closed by this pull request
leseb added a commit to leseb/praxis-ai that referenced this pull request Sep 14, 2026
Keep branch current with origin/main (praxis-proxy#1153, praxis-proxy#1133).

Signed-off-by: Sébastien Han <seb@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(responses): unify file_search under the agentic loop

2 participants