Value: Text agent frameworks and parser-oriented prompts often stop at a protocol delimiter. Supporting that behavior allows existing workflows to connect without removing a parameter or consuming text that belongs to the next stage. It also gives shell users an explicit boundary for extracting one answer from a locally generated text protocol.
Problem today: Sources/Core/ChatRequestValidator.swift:57 rejects every non-null stop; Sources/Server.swift:119 advertises it as unsupported, and Tests/integration/openapi_spec_test.py:522 locks in that rejection. Apple's sampling options do not need to grow a native stop field for apfel to provide an output-side boundary over the existing stream. A concrete client is LangChain's classic ReAct builder, which binds a stop delimiter by default: libs/langchain/langchain_classic/agents/react/agent.py:137.
Proposal: Accept OpenAI's string or string-array stop form for ordinary text generation, and expose repeatable CLI --stop. Implement a pure incremental matcher shared by HTTP and CLI. Hold back only the suffix that could become a delimiter; emit preceding text promptly, omit the matched delimiter and subsequent content, and cooperatively cancel generation after a match. Non-streaming requests should collect through the same mechanism so they can stop work early instead of generating the full response and cutting it afterward.
Use finish_reason:"stop" for a matched boundary. Limit the number and total size of delimiters, define earliest-match/tie behavior, and reject empty strings. Initially reject combinations with active tool calling or schema/JSON-constrained generation unless their structural validity is explicitly preserved. For text ReAct prompts, this request must be accepted:
curl --fail-with-body http://localhost:11434/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"apple-foundationmodel","messages":[{"role":"user","content":"Write one short action, then a line beginning Observation."}],"stop":["\nObservation"],"stream":true}'
Acceptance criteria:
- Write failing matcher tests before implementation for every split position, overlapping delimiters, shared prefixes, multiple matches, Unicode, and EOF with an unmatched prefix.
- Streaming and non-streaming produce identical delivered text for the same generated snapshots; no fragment of a completed delimiter reaches stdout/SSE.
- A match cancels the producer and releases the request's concurrency permit; already generated but withheld text is never emitted afterward.
- Validation covers string/array forms, null, empty values, excessive delimiters, and unsupported output-mode combinations; CLI flag tests cover all invalid combinations.
- Add a LangChain-shaped request regression and live HTTP/CLI smoke coverage; distinguish actual observed generation from delivered-output accounting in usage documentation.
Golden-goal alignment: Removes a concrete OpenAI client rejection and adds a composable text boundary to the UNIX tool.
Effort: M
Risks / open questions:
- FoundationModels cancellation is cooperative; do not promise zero extra internal work after the first matched delimiter.
- Prefix withholding adds bounded output latency, and cumulative framework snapshots need a tested Unicode-safe adapter.
- Supporting text stop sequences does not imply support for log probabilities, penalties, or other unavailable sampling controls.
Value: Text agent frameworks and parser-oriented prompts often stop at a protocol delimiter. Supporting that behavior allows existing workflows to connect without removing a parameter or consuming text that belongs to the next stage. It also gives shell users an explicit boundary for extracting one answer from a locally generated text protocol.
Problem today: Sources/Core/ChatRequestValidator.swift:57 rejects every non-null
stop;Sources/Server.swift:119advertises it as unsupported, andTests/integration/openapi_spec_test.py:522locks in that rejection. Apple's sampling options do not need to grow a native stop field for apfel to provide an output-side boundary over the existing stream. A concrete client is LangChain's classic ReAct builder, which binds a stop delimiter by default: libs/langchain/langchain_classic/agents/react/agent.py:137.Proposal: Accept OpenAI's string or string-array
stopform for ordinary text generation, and expose repeatable CLI--stop. Implement a pure incremental matcher shared by HTTP and CLI. Hold back only the suffix that could become a delimiter; emit preceding text promptly, omit the matched delimiter and subsequent content, and cooperatively cancel generation after a match. Non-streaming requests should collect through the same mechanism so they can stop work early instead of generating the full response and cutting it afterward.Use
finish_reason:"stop"for a matched boundary. Limit the number and total size of delimiters, define earliest-match/tie behavior, and reject empty strings. Initially reject combinations with active tool calling or schema/JSON-constrained generation unless their structural validity is explicitly preserved. For text ReAct prompts, this request must be accepted:Acceptance criteria:
Golden-goal alignment: Removes a concrete OpenAI client rejection and adds a composable text boundary to the UNIX tool.
Effort: M
Risks / open questions: