# Spec: Chat Completion Action ## Status Partially implemented. ## Scope This spec covers `chat.completion` request construction, response output, stream selection, model fallback, the `with.tools` agentic tool-calling loop, and structured output through `output_schema`, using a local OpenAI-compatible endpoint. No external model service is required. Provider-specific parameters, timeout, abort, and UI-facing tool sub-DAG drill-down tracking belong to executor and lifecycle coverage. ## Goal Workflow authors can send a prompt or message history to a model and use its response as step output. ## Behavior A nonempty `with.prompt` becomes a user message. When both `with.prompt` and `with.messages` are present, the prompt takes precedence. Otherwise, `with.messages` provides the ordered message history. `with.system`, when set, precedes the user messages. Streaming is enabled by default. `with.stream: false` selects a non-streaming request. Both modes write the response text to stdout, followed by a newline. A `with.model` array with multiple entries tries the models in order until one succeeds. Requests for these fallback models disable streaming. A single model does not activate fallback. ### Tool calling `with.tools` names other DAGs as callable functions. Each named DAG's declared parameters become the tool's JSON Schema (offered to the model as `tools` on every request), and its name becomes the tool name. A tool-calling request always uses a non-streaming call, regardless of `with.stream`. When the model's response includes tool calls, each one runs the corresponding DAG as a sub-DAG-run, passing the model's JSON arguments as DAG params. The sub-DAG's declared outputs (`output:`) are JSON-encoded and returned to the model as the tool result content, in a `tool` role message addressed to the tool call's ID. The loop then sends another request including that tool result, repeating until a response has no tool calls. A tool call naming a DAG outside `with.tools`, or whose sub-DAG-run itself fails, is not fatal to the step: the result content reports the problem to the model (`tool "" not found`, or the execution error), and the loop continues to request another response. `with.max_tool_iterations` bounds how many such request/tool-call rounds run, default 10. Reaching the limit does not fail the step: it is a second, equally successful termination path alongside the model responding with no further tool calls. A step with `output_schema` is the exception; see below. ### Structured output A step-level `output_schema` makes the model answer in that shape. The schema must declare `type: object`, list at least one property under `properties`, and list every `required` name under `properties`. Each request offers a `respond` tool whose parameters are the schema and requires a tool call (`tool_choice: required`). The first system message gains an instruction to answer through `respond`; when there is none, the instruction becomes the first message. The instruction is not part of the saved session. Structured requests never stream, regardless of `with.stream`. The answer is the arguments of the model's `respond` call. A reply without that call is accepted when its text is a JSON object, optionally inside a `json` code fence. Properties the schema does not list are dropped, and the remaining object is validated against the schema. The step writes the object to stdout as one line of JSON with keys in sorted order, and each listed property becomes `${steps..outputs.}`. Because only listed properties are published, a reference to any other output of the step is reported as unknown, even when the schema allows additional properties. An answer that is not a JSON object or does not match the schema gets one correction: the next request carries the rejected answer as an assistant message without tool calls, followed by a user message stating why it was rejected. When the corrected answer is also unusable, that model has failed and the next `with.model` entry is tried. With `with.tools`, the tool DAGs are offered next to `respond`. A response that calls `respond` ends the loop; other tool calls in that response do not run. Reaching `with.max_tool_iterations` without an answer fails that model, like an unusable answer: the next `with.model` entry starts over, and the step fails when no model is left. ### Files `with.files` names the files the model reads with the prompt: one path or glob pattern, several of them as a list or one per line, or a list an earlier step published, such as the emails `mail.search` found with `save_attachments: true`. A leading `~` is the home directory, and a relative path is under the step's working directory. Each file is read once, in the order named, before any request. A text file (`.txt`, `.md`, `.csv`, `.tsv`, `.json`, `.jsonl`, `.yaml`, `.yml`, `.xml`, `.html`, `.htm`, `.log`), a Word document (`.docx`) and an Excel workbook (`.xlsx`, `.xlsm`) are given as text, a paragraph or row a line; a text file in Shift_JIS is decoded. A PDF is given page by page, and a page without text of its own, such as a scan, is rendered to an image. An image file (PNG, JPEG, GIF, WebP) is given as an image, scaled down to a long edge of 1568 pixels when larger. Files of other kinds, a file over 50 MB, a missing file, and a pattern matching nothing fail the step before any request. The text goes ahead of the step's last user message, each file in a `` block, a PDF's pages set apart by `--- page N ---` lines; the images go with that message, in the order they are named, and the text names each by its place: `[Image 1 is receipt.png.]`, or `--- page 2: image 1 ---` for a scanned page. Together the files may hold up to 1,000,000 characters of text and 50 images. The session keeps the text, so a later step of the session still reads it; the images go with the one request and are not kept. A push-back resends the feedback without the files. `files` is refused in a DAG-level `llm` block, since it belongs with the prompt it goes with. ## Errors `dagu validate` rejects missing prompt/message input and a configured provider without a model. It exits nonzero with an error identifying the invalid configuration. With `output_schema`, `dagu validate` also rejects a schema without `type: object`, a schema that lists no properties, a `required` name that `properties` does not list, `with.web_search`, and a tool named `respond`. When no model gives a usable structured answer, the step fails with an error naming the last model and stating that no answer matched `output_schema`. The error contains no part of the rejected answers; the step's stderr receives each rejected answer with the reason. When the first fallback model rejects a request and the next succeeds, the step succeeds and writes the successful response. Exhausted fallback, provider-specific errors, and lifecycle failures are outside this conformance scope. ## Example ```yaml steps: - action: chat.completion with: provider: local model: local-model base_url: http://localhost:8080 prompt: Summarize the supplied text. stream: false ``` Reading an invoice, PDF or photographed, into typed values: ```yaml steps: - id: read action: chat.completion with: provider: local model: local-model base_url: http://localhost:8080 prompt: Read the invoice and give its number, date, and total. files: ~/Downloads/invoice-*.pdf output_schema: type: object properties: number: {type: string} date: {type: string} total: {type: number} required: [number] ``` Structured output, published as step outputs: ```yaml steps: - id: classify action: chat.completion with: provider: local model: local-model base_url: http://localhost:8080 prompt: "Classify this note and extract the amount: please refund my 12.50" output_schema: type: object properties: category: type: string enum: [refund, complaint, question] amount: type: number required: [category] - id: record depends: classify run: echo "${steps.classify.outputs.category}" ``` Tool calling, using another DAG in the same file as a tool: ```yaml steps: - action: chat.completion with: provider: local model: local-model base_url: http://localhost:8080 prompt: What is the weather in Paris? tools: - get-weather max_tool_iterations: 5 --- name: get-weather params: CITY steps: - command: fetch-weather.sh "$CITY" output: WEATHER_JSON ```