diff --git a/README.md b/README.md index af3e4b40b..5250b6b8f 100644 --- a/README.md +++ b/README.md @@ -207,15 +207,15 @@ is accepted for backward compatibility but is unnecessary there. ### Combining Agents and using MCP Servers -_To generate examples use `fast-agent quickstart workflow`. This example can be run with `uv run workflow/chaining.py`. fast-agent looks for configuration files in the current directory before checking parent directories recursively._ +_To generate examples use `fast-agent quickstart workflow`. This example can be run with `uv run workflow/chaining.py`. Place `fast-agent.yaml` in the active fast-agent home, or pass an explicit config path when needed._ -Agents can be chained to build a workflow, using MCP Servers defined in the `fastagent.config.yaml` file: +Agents can be chained to build a workflow, using MCP Servers defined in the `fast-agent.yaml` file: ```python @fast.agent( "url_fetcher", "Given a URL, provide a complete and comprehensive summary", - servers=["fetch"], # Name of an MCP Server defined in fastagent.config.yaml + servers=["fetch"], # Name of an MCP Server defined in fast-agent.yaml ) @fast.agent( "social_media", @@ -316,7 +316,7 @@ Extended example and all params sample is available in the repository as For SSE and HTTP MCP servers, OAuth is enabled by default with minimal configuration. A local callback server is used to capture the authorization code, with a paste-URL fallback if the port is unavailable. -- Minimal per-server settings in `fastagent.config.yaml`: +- Minimal per-server settings in `fast-agent.yaml`: ```yaml mcp: @@ -668,7 +668,7 @@ Prompts can also be applied interactively through the interactive interface by u ### Sampling -Sampling LLMs are configured per Client/Server pair. Specify the model name in fastagent.config.yaml as follows: +Sampling LLMs are configured per Client/Server pair. Specify the model name in fast-agent.yaml as follows: ```yaml mcp: @@ -683,7 +683,7 @@ mcp: ### Secrets File > [!TIP] -> fast-agent will look recursively for a fastagent.secrets.yaml file, so you only need to manage this at the root folder of your agent definitions. +> Put `fast-agent.secrets.yaml` alongside `fast-agent.yaml` in your active fast-agent home. Select a different home with `--env` or `FAST_AGENT_HOME`. ### Interactive Shell diff --git a/docs-internal/ACP_TERMINAL_SUPPORT.md b/docs-internal/ACP_TERMINAL_SUPPORT.md index c69ff1ab0..a267ce5ef 100644 --- a/docs-internal/ACP_TERMINAL_SUPPORT.md +++ b/docs-internal/ACP_TERMINAL_SUPPORT.md @@ -230,7 +230,7 @@ pytest tests/integration/acp/ -v Terminal support respects existing shell runtime configuration: ```yaml -# fastagent.config.yaml +# fast-agent.yaml shell_execution: timeout_seconds: 90 # Applies to both local and ACP terminals warning_interval_seconds: 30 diff --git a/docs-internal/ACP_TESTING.md b/docs-internal/ACP_TESTING.md index 9928b3e25..094bc9663 100644 --- a/docs-internal/ACP_TESTING.md +++ b/docs-internal/ACP_TESTING.md @@ -276,7 +276,7 @@ export ANTHROPIC_API_KEY="your-key-here" **Solution:** - Set environment variable: `export ANTHROPIC_API_KEY=...` an -- Or create config file: `~/.config/fast-agent/fastagent.secrets.yaml` +- Or create config file: `~/.config/fast-agent/fast-agent.secrets.yaml` ## Integration with Editors diff --git a/docs-internal/structured-docs.md b/docs-internal/structured-docs.md new file mode 100644 index 000000000..a4f1cf918 --- /dev/null +++ b/docs-internal/structured-docs.md @@ -0,0 +1,740 @@ +# Structured outputs + +fast-agent can ask a model to produce a final answer that matches a JSON Schema. +The runtime chooses the strongest structured-output mechanism available for the +selected model/provider, then validates the final answer locally. + +The user-facing goal is simple: + +- if you request structured output without tools, fast-agent makes one structured + model call; +- if tools are available, fast-agent uses the selected structured/tool policy to + decide whether tools are exposed, deferred, or combined with structured + constraints; +- the final answer is parsed and validated against the schema. + +## Structured output modes + +Model/provider metadata exposes the structured-output capability as: + +```python +json_mode: Literal["schema", "object"] | None +``` + +Meaning: + +| Mode | Behavior | +|---|---| +| `schema` | Use provider-native JSON Schema / structured-output constraints. | +| `object` | Use provider JSON-object mode plus prompt instructions and local validation. | +| `None` | Use provider-specific fallback or prompt/local validation, depending on provider. | + +Users normally do not need to choose between these modes. The model catalog and +overlays provide metadata, and fast-agent resolves the correct path. + +## Structured output with tools + +Structured output and regular tools are controlled by: + +```python +structured_tool_policy: Literal["auto", "always", "defer", "no_tools"] +``` + +| Policy | Behavior | +|---|---| +| `auto` | Use model/provider compatibility metadata and provider defaults. This is the normal default. | +| `always` | Send regular tools and structured constraints together in the same request. | +| `defer` | Use a two-phase flow: tools first, then a structured final answer without tools. | +| `no_tools` | Suppress regular tools and produce one structured answer immediately. | + +### `always` + +Use this when the model/provider reliably supports regular tool calls and +structured constraints in the same request. + +```text +turn 1: + regular tools: yes + structured constraints: yes +``` + +If the model calls a tool, the normal tool loop continues. Subsequent calls keep +the structured request active unless the provider has special handling. + +### `defer` + +Use this when a model or gateway cannot reliably combine regular tools and +structured constraints in one provider request. + +```text +turn 1: + regular tools: yes + structured constraints: no + +turn 2: + regular tools: no + structured constraints: yes +``` + +`defer` now finalizes even if the model does not call a tool on the first turn. +That preserves the contract that the final answer is structured. + +### `no_tools` + +Use this when structured output should be produced immediately and regular tools +should not be available for this call. + +```text +turn 1: + regular tools: no + structured constraints: yes +``` + +This is useful for extraction, classification, routing, or any structured task +where an agent may have tools generally, but this specific call should not use +them. + +## Model string overrides + +Structured-output behavior can be selected in model strings. + +Provider-specific structured mode: + +```text +sonnet?structured=json +sonnet?structured=tool_use +``` + +Structured/tools policy: + +```text +sonnet?structured_tools=always +sonnet?structured_tools=defer +sonnet?structured_tools=no_tools +sonnet?structured_tools=auto +``` + +Combined examples: + +```text +sonnet?structured=json&structured_tools=always +sonnet?structured=tool_use&structured_tools=defer +sonnet?structured=tool_use&structured_tools=no_tools +``` + +## Model overlays + +Local model overlays can describe structured-output capabilities: + +```yaml +metadata: + json_mode: object # schema | object | none/null + structured_tool_policy: defer # auto | always | defer | no_tools +``` + +Use `json_mode: none` or `json_mode: null` when provider-native structured modes +are unavailable or unreliable and prompt/local validation is preferred. + +Use `structured_tool_policy: defer` when a model should use tools first and then +produce a structured final answer. + +## Checking behavior + +The structured-tools probe verifies actual tool + structured-output behavior: + +```bash +fast-agent check structured-tools --model sonnet --json +fast-agent check structured-tools --models opus,opus46,sonnet,haiku --json +``` + +To compare policies: + +```bash +fast-agent check structured-tools --model sonnet --structured-tool-policy always --json +fast-agent check structured-tools --model sonnet --structured-tool-policy defer --json +``` + +The probe reports: + +- resolved provider/model; +- `json_mode`; +- selected structured tool policy; +- whether the tool was called; +- whether final JSON parsed and validated; +- whether the final JSON matched the tool payload. + +--- + +# Anthropic + +Anthropic has two structured-output mechanisms in fast-agent. + +## Native JSON Schema mode + +Selected with: + +```text +?structured=json +``` + +or automatically for Anthropic models whose metadata has: + +```python +json_mode = "schema" +``` + +This path uses Anthropic's native structured-output API: + +```python +output_config = { + "format": { + "type": "json_schema", + "schema": ... + } +} +``` + +and the Anthropic structured-output beta header. + +Properties: + +- strongest Anthropic structured-output path; +- compatible with reasoning/thinking; +- compatible with regular tools on current first-party Claude models; +- works well with `structured_tools=always`. + +Current first-party Anthropic models use this path by default, including: + +- `claude-opus-4-7` +- `claude-opus-4-6` +- `claude-sonnet-4-6` +- `claude-haiku-4-5` + +Example: + +```text +sonnet?structured=json&structured_tools=always +``` + +Empirical probe results for `haiku`, `opus46`, `opus`, and `sonnet` showed that +native JSON Schema mode can call regular tools and return valid schema-matching +JSON in the same structured tool flow. + +## Legacy `tool_use` structured output + +Selected with: + +```text +?structured=tool_use +``` + +or automatically for Anthropic models/providers that do not use native JSON +Schema mode. + +This path creates a synthetic Anthropic tool: + +```text +return_structured_output +``` + +and forces the model to call it: + +```python +tool_choice = { + "type": "tool", + "name": "return_structured_output" +} +``` + +The final structured JSON is read from that tool's input. + +Properties: + +- reliable legacy structured-output path; +- not compatible with Anthropic thinking/reasoning, so thinking is disabled for + that request; +- does not combine with regular tools in a single request, because the structured + output tool is forced. + +For this reason, when Anthropic's effective structured mode is `tool_use`, +`structured_tool_policy=auto` resolves to: + +```python +"no_tools" +``` + +That means: + +```text +sonnet?structured=tool_use +``` + +produces a single structured answer using only the synthetic +`return_structured_output` tool. + +## Using regular tools with Anthropic `tool_use` + +If you want regular tools to run before the final structured answer, choose +`defer` explicitly: + +```text +sonnet?structured=tool_use&structured_tools=defer +``` + +This produces: + +```text +turn 1: + regular tools: yes + structured output tool: no + +turn 2: + regular tools: no + structured output tool: yes, forced +``` + +This is the recommended way to use regular tools with Anthropic legacy +`tool_use` structured output. + +## Anthropic Vertex + +Anthropic Vertex currently does not advertise fast-agent's direct Anthropic +structured-output beta support in this provider path. As a result, it may fall +back to the legacy `tool_use` structured-output mechanism even for models whose +first-party Anthropic equivalents support native JSON Schema mode. + +Use: + +```text +anthropic-vertex.claude-sonnet-4-6?structured_tools=defer +``` + +when regular tools should be used before a structured final answer. + +Use: + +```text +anthropic-vertex.claude-sonnet-4-6?structured_tools=no_tools +``` + +when the request should produce structured output immediately without regular +tools. + +## Anthropic recommendations + +| Goal | Recommended setting | +|---|---| +| Current Claude model, structured output only | `?structured=json` or default `auto` | +| Current Claude model, regular tools and schema together | `?structured=json&structured_tools=always` | +| Anthropic legacy `tool_use`, no regular tools | `?structured=tool_use` | +| Anthropic legacy `tool_use`, tools first then structured output | `?structured=tool_use&structured_tools=defer` | +| Tool-capable agent but this call should not use tools | `?structured_tools=no_tools` | + +--- + +# OpenAI Responses and Codex Responses + +The Responses-family providers use the modern schema path in fast-agent: + +```python +json_mode = "schema" +``` + +This includes both: + +- `responses` +- `codexresponses` + +For these providers, structured output is represented as provider-native schema +constraints rather than prompt-only JSON instructions or legacy forced tool +output. + +## Structured output behavior + +Current Responses and Codex Responses models are expected to support regular +tools and structured schema constraints in the same request. + +In normal `auto` mode, these models resolve to same-request behavior: + +```text +turn 1: + regular tools: yes + structured constraints: yes +``` + +If the model calls a tool, the normal tool loop continues and the final answer is +validated against the schema. + +Recommended default: + +```python +json_mode = "schema" +structured_tool_policy = None # auto -> same-request provider default +``` + +Users normally do not need `defer` for these models. + +## Responses provider + +Current tested Responses models: + +| Model string | Resolved model | Structured mode | `structured_tools=auto` probe | +|---|---|---|---| +| `responses.gpt-5.5` | `gpt-5.5` | schema | PASS | +| `responses.gpt-5.4` | `gpt-5.4` | schema | PASS | +| `responses.gpt-5.4-mini` | `gpt-5.4-mini` | schema | PASS | +| `responses.gpt-5.4-nano` | `gpt-5.4-nano` | schema | PASS | +| `responses.gpt-5.3-chat-latest` | `gpt-5.3-chat-latest` | schema | PASS | +| `responses.gpt-5.3-codex` | `gpt-5.3-codex` | schema | PASS | +| `responses.gpt-5.2` | `gpt-5.2` | schema | PASS | + +Probe result meaning: + +- the model called `get_probe_payload`; +- the final answer parsed as JSON; +- the final answer validated against the schema; +- the final JSON matched the tool payload. + +Example: + +```bash +fast-agent check structured-tools \ + --models responses.gpt-5.5,responses.gpt-5.4,responses.gpt-5.3-codex \ + --structured-tool-policy auto \ + --json +``` + +## Codex Responses provider + +Codex Responses uses the same schema path, but dispatches through the Codex OAuth +provider: + +```text +codexresponses. +``` + +Current tested Codex Responses aliases: + +| Alias | Resolved model | Structured mode | `structured_tools=auto` probe | +|---|---|---|---| +| `codexplan` | `gpt-5.5` | schema | PASS | +| `codexplan54` | `gpt-5.4` | schema | PASS | +| `codexplan53` | `gpt-5.3-codex` | schema | PASS | +| `codexspark` | `gpt-5.3-codex-spark` | schema | PASS | + +Example: + +```bash +fast-agent check structured-tools \ + --models codexplan,codexplan54,codexplan53,codexspark \ + --structured-tool-policy auto \ + --json +``` + +## Removed unsupported Codex aliases + +The following older Codex Responses aliases/models were removed after live +testing showed they are not supported with the current ChatGPT/Codex account +path: + +- `codexplan52` +- `codexplan51` +- `gpt-5.2-codex` +- `gpt-5.1-codex` + +Observed provider errors: + +```text +The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account. +The 'gpt-5.1-codex' model is not supported when using Codex with a ChatGPT account. +``` + +Use one of the current aliases instead: + +```text +codexplan +codexplan54 +codexplan53 +codexspark +``` + +## Responses recommendations + +| Goal | Recommended setting | +|---|---| +| Structured output only | default `auto` or `?structured_tools=no_tools` if tools are present but should be ignored | +| Structured output with regular tools | default `auto` | +| Force same-request schema + tools | `?structured_tools=always` | +| Tool-first/two-phase behavior | usually unnecessary; use `?structured_tools=defer` only for experimentation or a specific gateway issue | + +--- + +# xAI Grok + +xAI Grok models use the provider-native JSON Schema path in fast-agent: + +```python +json_mode = "schema" +``` + +The current Grok models tested support regular tools and structured schema +constraints in the same request. + +Recommended default: + +```python +json_mode = "schema" +structured_tool_policy = None # auto -> same-request provider default +``` + +## Current xAI probe results + +The `--json-schema` CLI path was smoke-tested with a schema containing an +optional nullable field with `default: null`: + +```bash +fast-agent go \ + --model xai.grok-4.3 \ + --message "Return answer='ok' and context=null." \ + --json-schema /tmp/fast-agent-xai-structured-smoke.schema.json \ + --quiet +``` + +Observed output: + +```json +{"answer": "ok", "context": null} +``` + +Structured output with regular tools was tested with: + +```bash +fast-agent check structured-tools \ + --models xai.grok-4-fast-non-reasoning,xai.grok-4-fast-reasoning,xai.grok-4-1-fast-non-reasoning,xai.grok-4-1-fast-reasoning \ + --structured-tool-policy always \ + --json +``` + +and individually for the 4.3 aliases. + +| Model string | Resolved model | Structured mode | `structured_tools=always` probe | +|---|---|---|---| +| `xai.grok-4.3` | `grok-4.3` | schema | PASS | +| `xai.grok-4.3-latest` | `grok-4.3-latest` | schema | PASS | +| `xai.grok-4-fast-non-reasoning` | `grok-4-fast-non-reasoning` | schema | PASS | +| `xai.grok-4-fast-reasoning` | `grok-4-fast-reasoning` | schema | PASS | +| `xai.grok-4-1-fast-non-reasoning` | `grok-4-1-fast-non-reasoning` | schema | PASS | +| `xai.grok-4-1-fast-reasoning` | `grok-4-1-fast-reasoning` | schema | PASS | + +Additional one-shot `--json-schema` smokes passed for: + +- `xai.grok-4-fast-non-reasoning` +- `xai.grok-4-1-fast-non-reasoning` +- `xai.grok-4.3` +- `xai.grok-4.3-latest` + +There is currently no `grok-4.2` / `grok-4-2` entry in the fast-agent model +catalog. + +## xAI recommendations + +| Goal | Recommended setting | +|---|---| +| Structured output only | default `auto` or `?structured_tools=no_tools` if tools are present but should be ignored | +| Structured output with regular tools | default `auto` | +| Force same-request schema + tools | `?structured_tools=always` | +| Tool-first/two-phase behavior | unnecessary based on current probe results | + +--- + +# Google native Gemini + +Google native uses the `google.genai` `GenerateContentConfig` structured output +surface: + +```python +config.response_mime_type = "application/json" +config.response_schema = schema +``` + +fast-agent records current Gemini text/vision models as `json_mode="schema"`. + +## Current native Gemini aliases + +As of 2026-05-03, `google.genai` model listing showed these Gemini model IDs as +active for `generateContent` and relevant to the text/chat path: + +| Alias | Model | +|---|---| +| `gemini` | `gemini-3.1-pro-preview` | +| `gemini3.1` | `gemini-3.1-pro-preview` | +| `gemini3.1flashlite` | `gemini-3.1-flash-lite-preview` | +| `gemini3` | `gemini-3-pro-preview` | +| `gemini3flash` | `gemini-3-flash-preview` | +| `gemini25` | `gemini-2.5-flash` | +| `gemini25pro` | `gemini-2.5-pro` | +| `gemini2` | `gemini-2.0-flash` | + +Removed inactive dated/legacy Gemini 2.5 preview IDs from the native Google +catalog: + +- `gemini-2.5-flash-preview` +- `gemini-2.5-pro-preview` +- `gemini-2.5-flash-preview-05-20` +- `gemini-2.5-pro-preview-05-06` +- `gemini-2.5-flash-preview-09-2025` + +## Structured output with tools + +Google native supports combining `response_schema` and regular function tools in +the same request for some, but not all, current Gemini models. Direct fast-agent +probes produced this matrix: + +| Model | `always` | `defer` | Default policy | Notes | +|---|---:|---:|---|---| +| `gemini-3.1-pro-preview` | pass | — | `always` | Same-request schema + tools worked. | +| `gemini-3-pro-preview` | pass | — | `always` | Same-request schema + tools worked. | +| `gemini-3-flash-preview` | pass | — | `always` | Same-request schema + tools worked. | +| `gemini-3.1-flash-lite-preview` | fail | pass | `no_tools` | Repeated tool calls instead of finalizing under `always`. | +| `gemini-2.5-pro` | fail | pass | `no_tools` | API rejected function calling with `application/json` response MIME type. | +| `gemini-2.5-flash` | fail | pass | `no_tools` | API rejected function calling with `application/json` response MIME type. | +| `gemini-2.0-flash` | fail | not probed | `no_tools` | Repeated tool calls instead of finalizing under `always`. | + +fast-agent allows Google native structured requests to keep tools when the +resolved structured-tools policy is `always`, but models that failed the +same-request probe are registered with `structured_tool_policy="no_tools"` so +default `auto` remains a structured single call. Use `structured_tools=defer` +when tool-informed structured output is required for those models. + +`no_tools` still suppresses regular tools for a structured-only answer, and +`defer` remains available as a two-phase tools-first mode: + +```text +google.gemini-3-flash-preview?structured_tools=no_tools +google.gemini-3-flash-preview?structured_tools=defer +google.gemini-3.1-flash-lite-preview?structured_tools=defer +``` + +## Google recommendations + +| Goal | Recommended setting | +|---|---| +| Structured output only | default `auto`, or `?structured_tools=no_tools` if regular tools are configured but should be ignored | +| Structured output with regular tools | default `auto` | +| Force same-request schema + tools | `?structured_tools=always` | +| Tool-first/two-phase behavior | `?structured_tools=defer` | + +--- + +# Hugging Face Inference Providers + +Hugging Face routed models are the most variable structured-output surface in +fast-agent. The same model family can behave differently depending on the +selected inference provider (`novita`, `fireworks-ai`, `together`, `cerebras`, +and so on). + +For this reason, HF support should be maintained as a measured compatibility +matrix rather than guessed from model family alone. + +## Two-pass HF compatibility process + +### Pass 1: choose the structured mode + +For each model route, test structured output without regular tools in this +order: + +1. `json_mode="schema"` +2. `json_mode="object"` +3. `json_mode=None` + +Choose the strongest mode that: + +- does not produce a provider error; +- returns parseable JSON; +- validates against the schema; +- matches the requested payload. + +### Pass 2: choose the structured/tools policy + +Using the recommended mode from pass 1: + +1. test `structured_tools=always`; +2. if that fails, test `structured_tools=defer` as an explicit tools-first mode; +3. if `always` fails, use `no_tools` as the default policy so `auto` remains a + single structured call unless the user explicitly asks for `defer`. + +## Matrix probe script + +The work-in-progress matrix probe lives at: + +```bash +scripts/probe_structured_support_matrix.py +``` + +Example: + +```bash +uv run scripts/probe_structured_support_matrix.py \ + --models kimi26,qwen35,glm51,minimax25,gpt-oss,gpt-oss-20b,deepseek4pro +``` + +The script: + +- temporarily tests each model with `schema`, `object`, and prompt/local mode; +- recommends a `json_mode`; +- tests `always`; +- tests `defer` when `always` fails; +- prints a table and JSON payload. + +## Initial HF probe results + +These results are from quick live probes and should be treated as an initial +support matrix, not a permanent guarantee. Provider routes change frequently. + +| Model | Route | Recommended structured mode | `always` | `defer` | Default policy | Tool-informed policy | Notes | +|---|---|---:|---:|---:|---|---|---| +| `hf.moonshotai/Kimi-K2-Instruct-0905:novita` | Novita | `schema` | pass | — | `always` | `always` | Older Kimi route is still live on Novita. | +| `hf.moonshotai/Kimi-K2-Thinking:novita` | Novita | `schema` | fail | pass | `no_tools` | `defer` | Older thinking route is still live; use `defer` when tools should inform output. | +| `kimi25` | `moonshotai/Kimi-K2.5:novita` | `schema` | pass | — | `always` | `always` | Schema appears viable on Novita. | +| `kimi26` | `moonshotai/Kimi-K2.6:novita` | `schema` | fail | pass | `no_tools` | `defer` | Use `defer` explicitly for tool-informed structured output. | +| `qwen35` | `Qwen/Qwen3.5-397B-A17B:novita` | `schema` | fail | pass | `no_tools` | `defer` | Schema appears viable; default suppresses tools. | +| `glm51` | `zai-org/GLM-5.1:together` | `schema` | fail | pass | `no_tools` | `defer` | Provider rejected combined response format + tools under `always`. | +| `minimax25` | `MiniMaxAI/MiniMax-M2.5:fireworks-ai` | `schema` | fail | pass | `no_tools` | `defer` | Provider rejected combined response format + tools under `always`. | +| `gpt-oss` | `openai/gpt-oss-120b:cerebras` | `schema` | fail | pass | `no_tools` | `defer` | Provider rejected `tools` with `response_format` under `always`. | +| `gpt-oss-20b` | default HF route | `schema` | fail | pass | `no_tools` | `defer` | Provider rejected JSON mode with tool/function calling under `always`. | +| `deepseek4pro` | `deepseek-ai/DeepSeek-V4-Pro:fireworks-ai` | `schema` | fail | pass | `no_tools` | `defer` | Use `defer` explicitly for tool-informed structured output. | + +Additional legacy checks: + +| Model | Recommended structured mode | `always` | `defer` | Recommended policy | Notes | +|---|---:|---:|---:|---|---| +| `glm5` | `schema` | pass | — | `always` | Older GLM route still works in same-request mode. | +| `minimax21` | `schema` | pass | — | `always` | Older MiniMax route still works in same-request mode. | +| `glm47` | `schema` | fail | fail | `no_tools` pending investigation | Failure involved provider rejecting replayed `reasoning_content`; may need provider-specific cleanup rather than a model capability change. | +| `deepseek31` | `schema` | fail | fail | `no_tools` pending investigation | Same caveat: failure involved provider/request replay behavior. | +| `deepseek32` | `schema` | fail | fail | `no_tools` pending investigation | Same caveat: failure involved provider/request replay behavior. | + +## HF availability observations + +Recent provider lookup showed: + +- `moonshotai/Kimi-K2-Instruct-0905` is still live on `novita` and + `featherless-ai`; +- `moonshotai/Kimi-K2-Thinking` is still live on `novita` and `featherless-ai`; +- `moonshotai/Kimi-K2` and `moonshotai/Kimi-K2-Thinking-0905` had no active HF + providers; +- older GLM, MiniMax, DeepSeek, Qwen, and GPT-OSS routes are mostly still live. + +This suggests HF cleanup should mostly distinguish current vs legacy aliases +rather than deleting older models just because they are no longer preferred. + +## HF recommendations + +| Situation | Recommendation | +|---|---| +| New/current HF route | run the two-pass matrix before changing catalog metadata | +| `always` rejects `tools` + `response_format` | set `structured_tool_policy="no_tools"` as the default; document that `defer` works when tool-informed output is required | +| schema passes without tools | prefer `json_mode="schema"` unless route-specific tool probing shows problems | +| schema fails but object passes | use `json_mode="object"` | +| native modes are unreliable | use `json_mode=None` and local validation | +| route is live but no longer preferred | keep metadata if useful, but mark selector alias non-current | diff --git a/examples/azure-openai/fastagent.config.yaml b/examples/azure-openai/fast-agent.yaml similarity index 100% rename from examples/azure-openai/fastagent.config.yaml rename to examples/azure-openai/fast-agent.yaml diff --git a/examples/bedrock/fast-agent.config.yaml b/examples/bedrock/fast-agent.yaml similarity index 96% rename from examples/bedrock/fast-agent.config.yaml rename to examples/bedrock/fast-agent.yaml index a2a88f3bc..cd4ec47ed 100644 --- a/examples/bedrock/fast-agent.config.yaml +++ b/examples/bedrock/fast-agent.yaml @@ -1,4 +1,4 @@ -# Example minimal fast-agent.config.yaml for Bedrock +# Example minimal fast-agent.yaml for Bedrock # List of supported models: https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html # Model string format: bedrock.?reasoning= diff --git a/examples/custom-agents/fastagent.config.yaml b/examples/custom-agents/fast-agent.yaml similarity index 100% rename from examples/custom-agents/fastagent.config.yaml rename to examples/custom-agents/fast-agent.yaml diff --git a/examples/data-analysis/fastagent.config.yaml b/examples/data-analysis/fast-agent.yaml similarity index 100% rename from examples/data-analysis/fastagent.config.yaml rename to examples/data-analysis/fast-agent.yaml diff --git a/examples/experimental/mcp_sessions/demo/fastagent.config.yaml b/examples/experimental/mcp_sessions/demo/fast-agent.yaml similarity index 100% rename from examples/experimental/mcp_sessions/demo/fastagent.config.yaml rename to examples/experimental/mcp_sessions/demo/fast-agent.yaml diff --git a/examples/mcp/elicitations/fastagent.secrets.yaml.example b/examples/mcp/elicitations/fast-agent.secrets.yaml.example similarity index 82% rename from examples/mcp/elicitations/fastagent.secrets.yaml.example rename to examples/mcp/elicitations/fast-agent.secrets.yaml.example index 3cbdb4f1d..1ccea9ea9 100644 --- a/examples/mcp/elicitations/fastagent.secrets.yaml.example +++ b/examples/mcp/elicitations/fast-agent.secrets.yaml.example @@ -1,6 +1,6 @@ # Secrets configuration for elicitation examples # -# Rename this file to fastagent.secrets.yaml and add your API keys +# Rename this file to fast-agent.secrets.yaml and add your API keys # to use the account_creation.py example with real LLMs # OpenAI diff --git a/examples/mcp/elicitations/fastagent.config.yaml b/examples/mcp/elicitations/fast-agent.yaml similarity index 100% rename from examples/mcp/elicitations/fastagent.config.yaml rename to examples/mcp/elicitations/fast-agent.yaml diff --git a/examples/mcp/mcp-filtering/fastagent.secrets.yaml.example b/examples/mcp/mcp-filtering/fast-agent.secrets.yaml.example similarity index 100% rename from examples/mcp/mcp-filtering/fastagent.secrets.yaml.example rename to examples/mcp/mcp-filtering/fast-agent.secrets.yaml.example diff --git a/examples/mcp/mcp-filtering/fastagent.config.yaml b/examples/mcp/mcp-filtering/fast-agent.yaml similarity index 100% rename from examples/mcp/mcp-filtering/fastagent.config.yaml rename to examples/mcp/mcp-filtering/fast-agent.yaml diff --git a/examples/mcp/sampling-with-tools/fastagent.config.yaml b/examples/mcp/sampling-with-tools/fast-agent.yaml similarity index 100% rename from examples/mcp/sampling-with-tools/fastagent.config.yaml rename to examples/mcp/sampling-with-tools/fast-agent.yaml diff --git a/examples/mcp/state-transfer/fastagent.secrets.yaml.example b/examples/mcp/state-transfer/fast-agent.secrets.yaml.example similarity index 100% rename from examples/mcp/state-transfer/fastagent.secrets.yaml.example rename to examples/mcp/state-transfer/fast-agent.secrets.yaml.example diff --git a/examples/mcp/state-transfer/fastagent.config.yaml b/examples/mcp/state-transfer/fast-agent.yaml similarity index 100% rename from examples/mcp/state-transfer/fastagent.config.yaml rename to examples/mcp/state-transfer/fast-agent.yaml diff --git a/examples/mcp/structured-content-preview/fastagent.config.yaml b/examples/mcp/structured-content-preview/fast-agent.yaml similarity index 100% rename from examples/mcp/structured-content-preview/fastagent.config.yaml rename to examples/mcp/structured-content-preview/fast-agent.yaml diff --git a/examples/mcp/vision-examples/fastagent.config.yaml b/examples/mcp/vision-examples/fast-agent.yaml similarity index 100% rename from examples/mcp/vision-examples/fastagent.config.yaml rename to examples/mcp/vision-examples/fast-agent.yaml diff --git a/examples/new-api/fastagent.config.yaml b/examples/new-api/fast-agent.yaml similarity index 100% rename from examples/new-api/fastagent.config.yaml rename to examples/new-api/fast-agent.yaml diff --git a/examples/new-api/textual_markdown_demo.py b/examples/new-api/textual_markdown_demo.py index 2a870654a..d38b07097 100644 --- a/examples/new-api/textual_markdown_demo.py +++ b/examples/new-api/textual_markdown_demo.py @@ -36,7 +36,7 @@ ) DEFAULT_MODEL = "kimi" CHAT_AGENT_NAME = "textual_markdown_chat" -CONFIG_PATH = Path(__file__).with_name("fastagent.config.yaml") +CONFIG_PATH = Path(__file__).with_name("fast-agent.yaml") fast = FastAgent( "Textual Markdown Demo", diff --git a/examples/openapi/fastagent.config.yaml b/examples/openapi/fast-agent.yaml similarity index 100% rename from examples/openapi/fastagent.config.yaml rename to examples/openapi/fast-agent.yaml diff --git a/examples/otel/fastagent.config.yaml b/examples/otel/fast-agent.yaml similarity index 100% rename from examples/otel/fastagent.config.yaml rename to examples/otel/fast-agent.yaml diff --git a/examples/rag/vertex-rag.py b/examples/rag/vertex-rag.py index a1fd65cc7..f7b3d786d 100644 --- a/examples/rag/vertex-rag.py +++ b/examples/rag/vertex-rag.py @@ -22,8 +22,8 @@ # Create a RAG Corpus, Import Files, and Generate a response # uv pip install google-api-python-client -# TODO(developer): Update PROJECT_ID, LOCATION fastagent.config.yaml -CONFIG_PATH = "fastagent.secrets.yaml" +# TODO(developer): Update PROJECT_ID, LOCATION fast-agent.yaml +CONFIG_PATH = "fast-agent.secrets.yaml" # google: # vertex_ai: @@ -44,7 +44,7 @@ if not PROJECT_ID or not LOCATION: raise ValueError( - "Missing google.vertex_ai.project_id/location in fastagent.secrets.yaml" + "Missing google.vertex_ai.project_id/location in fast-agent.secrets.yaml" ) diff --git a/examples/researcher/fastagent.config.yaml b/examples/researcher/fast-agent.yaml similarity index 95% rename from examples/researcher/fastagent.config.yaml rename to examples/researcher/fast-agent.yaml index 2e22aa745..5981b0560 100644 --- a/examples/researcher/fastagent.config.yaml +++ b/examples/researcher/fast-agent.yaml @@ -18,7 +18,7 @@ mcp: # target: "node c:/Program Files/nodejs/node_modules/@modelcontextprotocol/server-brave-search/dist/index.js" target: "npx -y @modelcontextprotocol/server-brave-search" env: - # You can also place your BRAVE_API_KEY in the fastagent.secrets.yaml file. + # You can also place your BRAVE_API_KEY in the fast-agent.secrets.yaml file. BRAVE_API_KEY: - name: filesystem # On windows update the target to use `node` and the absolute path to the server. diff --git a/examples/setup/.gitignore b/examples/setup/.gitignore index dd10fe229..7f0aa842e 100644 --- a/examples/setup/.gitignore +++ b/examples/setup/.gitignore @@ -1,5 +1,5 @@ # Secrets -fastagent.secrets.yaml +fast-agent.secrets.yaml # Python cache/bytecode __pycache__/ diff --git a/examples/setup/agent.py b/examples/setup/agent.py index 29a7b037e..ee8e53ffc 100644 --- a/examples/setup/agent.py +++ b/examples/setup/agent.py @@ -13,6 +13,8 @@ {{file_silent:AGENTS.md}} {{env}} +{{model_specific}} + The current date is {{currentDate}}.""" diff --git a/examples/setup/fastagent.secrets.yaml.example b/examples/setup/fast-agent.secrets.yaml.example similarity index 100% rename from examples/setup/fastagent.secrets.yaml.example rename to examples/setup/fast-agent.secrets.yaml.example diff --git a/examples/setup/fastagent.config.yaml b/examples/setup/fast-agent.yaml similarity index 100% rename from examples/setup/fastagent.config.yaml rename to examples/setup/fast-agent.yaml diff --git a/examples/tensorzero/agent.py b/examples/tensorzero/agent.py index 438a6ff1e..9845040f9 100644 --- a/examples/tensorzero/agent.py +++ b/examples/tensorzero/agent.py @@ -4,7 +4,7 @@ from fast_agent.llm.request_params import RequestParams # Explicitly provide the path to the config file in the current directory -CONFIG_FILE = "fastagent.config.yaml" +CONFIG_FILE = "fast-agent.yaml" fast = FastAgent("fast-agent example", config_path=CONFIG_FILE, ignore_unknown_args=True) # Define T0 system variables here diff --git a/examples/tensorzero/fastagent.config.yaml b/examples/tensorzero/fast-agent.yaml similarity index 100% rename from examples/tensorzero/fastagent.config.yaml rename to examples/tensorzero/fast-agent.yaml diff --git a/examples/tensorzero/simple_agent.py b/examples/tensorzero/simple_agent.py index ac556e4cb..37b0d49e2 100644 --- a/examples/tensorzero/simple_agent.py +++ b/examples/tensorzero/simple_agent.py @@ -2,7 +2,7 @@ from fast_agent import FastAgent -CONFIG_FILE = "fastagent.config.yaml" +CONFIG_FILE = "fast-agent.yaml" fast = FastAgent("fast-agent example", config_path=CONFIG_FILE, ignore_unknown_args=True) diff --git a/examples/tool-runner-hooks/fastagent.config.yaml b/examples/tool-runner-hooks/fast-agent.yaml similarity index 100% rename from examples/tool-runner-hooks/fastagent.config.yaml rename to examples/tool-runner-hooks/fast-agent.yaml diff --git a/examples/tool-use-agent/fastagent.config.yaml b/examples/tool-use-agent/fast-agent.yaml similarity index 100% rename from examples/tool-use-agent/fastagent.config.yaml rename to examples/tool-use-agent/fast-agent.yaml diff --git a/examples/workflows-md/fastagent.config.yaml b/examples/workflows-md/fast-agent.yaml similarity index 100% rename from examples/workflows-md/fastagent.config.yaml rename to examples/workflows-md/fast-agent.yaml diff --git a/examples/workflows-md/hf-api-agent/fastagent.config.yaml b/examples/workflows-md/hf-api-agent/fast-agent.yaml similarity index 100% rename from examples/workflows-md/hf-api-agent/fastagent.config.yaml rename to examples/workflows-md/hf-api-agent/fast-agent.yaml diff --git a/examples/workflows-md/maker/classifier.md b/examples/workflows-md/maker/classifier.md index f21e60494..f242601fc 100644 --- a/examples/workflows-md/maker/classifier.md +++ b/examples/workflows-md/maker/classifier.md @@ -1,7 +1,7 @@ --- type: agent name: classifier -model: claude-3-haiku-20240307 +model: claude-haiku-4-5 --- You are a customer support intent classifier. Classify the customer message into exactly one of: COMPLAINT, QUESTION, REQUEST, FEEDBACK. diff --git a/examples/workflows/agents_as_tools_extended.py b/examples/workflows/agents_as_tools_extended.py index 2e74f4000..d6fa1bd4b 100644 --- a/examples/workflows/agents_as_tools_extended.py +++ b/examples/workflows/agents_as_tools_extended.py @@ -57,7 +57,7 @@ servers=[ "time", "fetch", - ], # MCP servers 'time' and 'fetch' configured in fastagent.config.yaml + ], # MCP servers 'time' and 'fetch' configured in fast-agent.yaml ) @fast.agent( name="London-Project-Manager", diff --git a/examples/workflows/fastagent.config.yaml b/examples/workflows/fast-agent.yaml similarity index 100% rename from examples/workflows/fastagent.config.yaml rename to examples/workflows/fast-agent.yaml diff --git a/examples/workflows/maker.py b/examples/workflows/maker.py index cdcedd3c3..cd7f62b2c 100644 --- a/examples/workflows/maker.py +++ b/examples/workflows/maker.py @@ -71,7 +71,7 @@ # on ambiguous messages, which is why we wrap it with MAKER for reliability @fast.agent( name="classifier", - model="claude-3-haiku-20240307", + model="claude-haiku-4-5", instruction="""You are a customer support intent classifier. Classify the customer message into exactly one of: COMPLAINT, QUESTION, REQUEST, FEEDBACK. Respond with ONLY the single word classification, nothing else. diff --git a/publish/hf-inference-acp/README.md b/publish/hf-inference-acp/README.md index cff5a8526..3be62fc04 100644 --- a/publish/hf-inference-acp/README.md +++ b/publish/hf-inference-acp/README.md @@ -45,7 +45,7 @@ ACP sessions are always connection-scoped. - `kimi` - `glm` - `minimax` -- `deepseek32` +- `deepseek4pro` - `kimi25` (thinking profile) - `qwen35` (thinking profile) - `qwen35instruct` (instruct profile) diff --git a/publish/hf-inference-acp/src/hf_inference_acp/agents.py b/publish/hf-inference-acp/src/hf_inference_acp/agents.py index bff175e0f..495e77e56 100644 --- a/publish/hf-inference-acp/src/hf_inference_acp/agents.py +++ b/publish/hf-inference-acp/src/hf_inference_acp/agents.py @@ -103,8 +103,8 @@ def _normalize_hf_model(model: str) -> str: have the hf. prefix, add it automatically. Examples: - moonshotai/Kimi-K2-Thinking:together -> hf.moonshotai/Kimi-K2-Thinking:together - hf.moonshotai/Kimi-K2-Thinking:together -> hf.moonshotai/Kimi-K2-Thinking:together + deepseek-ai/DeepSeek-V4-Pro:fireworks-ai -> hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai + hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai -> hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai kimi -> kimi (alias, unchanged) gpt-4o -> gpt-4o (no /, unchanged) """ diff --git a/publish/hf-inference-acp/src/hf_inference_acp/wizard/model_catalog.py b/publish/hf-inference-acp/src/hf_inference_acp/wizard/model_catalog.py index 46c37da23..186c3a30f 100644 --- a/publish/hf-inference-acp/src/hf_inference_acp/wizard/model_catalog.py +++ b/publish/hf-inference-acp/src/hf_inference_acp/wizard/model_catalog.py @@ -39,14 +39,9 @@ def _get_model_string(alias: str) -> str: description="MiniMax-M2.1, Optimized specifically for robustness in coding, tool use, instruction following, and long-horizon planning.", ), CuratedModel( - id="deepseek32", - display_name="DeepSeek 3.2", - description=" DeepSeek-V3.2, a model that harmonizes high computational efficiency with superior reasoning and agent performance.", - ), - CuratedModel( - id="kimithink", - display_name="Kimi K2 Thinking", - description="Advanced reasoning model with extended thinking", + id="deepseek4pro", + display_name="DeepSeek V4 Pro", + description="DeepSeek-V4-Pro via Fireworks AI, with a 1M token context window and native reasoning stream output.", ), CuratedModel( id="gpt-oss", @@ -152,7 +147,7 @@ def format_model_list_help() -> str: "- `/set-model glm` - Use GLM 4.6", "- `/set-model kimi25instant` - Use Kimi 2.5 instant profile", "- `/set-model qwen35instruct` - Use Qwen 3.5 with instruct sampling profile", - "- `/set-model moonshotai/Kimi-K2-Thinking` - Set model (autoroute) and show providers", + "- `/set-model deepseek4pro` - Use DeepSeek V4 Pro", "", "## Model String Format", "", diff --git a/pyproject.toml b/pyproject.toml index 7c817c50e..a5a1172af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fast-agent-mcp" -version = "0.6.26" +version = "0.7.0" description = "Define, Prompt and Test MCP enabled Agents and Workflows" readme = "README.md" license = { file = "LICENSE" } @@ -14,16 +14,16 @@ classifiers = [ ] requires-python = ">=3.13.5,<3.15" dependencies = [ - "fastapi==0.136.0", - "fastmcp==3.2.3", + "fastapi==0.136.1", + "fastmcp==3.2.4", "mcp==1.27.0", "pydantic-settings==2.13.0", - "pydantic==2.13.1", + "pydantic==2.13.3", "pyyaml==6.0.3", "rich==14.3.3", "typer==0.24.1", "anthropic[vertex]==0.97.0", - "openai[aiohttp]==2.32.0", + "openai[aiohttp]==2.33.0", "prompt-toolkit==3.0.52", "aiohttp==3.13.5", "opentelemetry-distro==0.60b1", @@ -32,7 +32,7 @@ dependencies = [ "opentelemetry-instrumentation-anthropic==0.52.1; python_version >= '3.10' and python_version < '4.0'", "opentelemetry-instrumentation-mcp==0.52.1; python_version >= '3.10' and python_version < '4.0'", "opentelemetry-instrumentation-google-genai==0.6b0", - "google-genai==1.73.1", + "google-genai==1.74.0", "deprecated==1.3.1", "a2a-sdk==0.3.26", "email-validator==2.2.0", diff --git a/resources/shared/model_overlays.md b/resources/shared/model_overlays.md index 0dd979cb5..31980c960 100644 --- a/resources/shared/model_overlays.md +++ b/resources/shared/model_overlays.md @@ -50,6 +50,10 @@ defaults: metadata: context_window: 75264 max_output_tokens: 2048 + model_specific: | + Use terse responses for this local model. + json_mode: object + structured_tool_policy: defer picker: label: Qwen local description: Imported from llama.cpp @@ -147,10 +151,21 @@ Common fields: - `context_window` - `max_output_tokens` - `tokenizes` +- `json_mode`: `schema`, `object`, `none`, or `null` +- `structured_tool_policy`: `auto`, `always`, `defer`, or `no_tools` +- `model_specific`: text made available to system prompts as `{{model_specific}}` - `fast` Use this for models that are not part of the built-in catalog or when local runtime limits differ from known defaults. +`json_mode` describes how fast-agent should request structured output: + +- `schema` — provider-native JSON Schema constraints +- `object` — provider JSON-object mode plus prompt instructions/local validation +- `none`/`null` — prompt-only structured guidance plus local validation + +`structured_tool_policy: no_tools` suppresses regular tools and produces one structured response. `defer` uses tools first, then produces the final schema-constrained answer without tools. Use `defer` when a model should use tools to gather data before returning structured JSON. + --- ## Picker diff --git a/resources/shared/smart_prompt.md b/resources/shared/smart_prompt.md index 9329b8a1c..f824db0d8 100644 --- a/resources/shared/smart_prompt.md +++ b/resources/shared/smart_prompt.md @@ -34,7 +34,7 @@ Use `attach_resource` when you want to send a prompt with one resource attached. Use `slash_command` when you need interactive-style `/...` command behavior (for example `/mcp ...`, `/skills ...`, `/cards ...`). When calling child-agent tools (`agent__*`), follow each tool's schema and parameter descriptions exactly. -When a card needs MCP servers that are not preconfigured in `fastagent.config.yaml`, +When a card needs MCP servers that are not preconfigured in `fast-agent.yaml`, declare them with `mcp_connect` entries (`target` + optional `name`). Prefer explicit `name` values when collisions are possible. For provider-managed remote MCP, use `management: provider`. For OpenAI Responses connectors, use structured @@ -45,4 +45,6 @@ remote tool or connector loading. Mermaid diagrams between code fences are supported. +{{model_specific}} + The current date is {{currentDate}}. diff --git a/scripts/probe_structured_support_matrix.py b/scripts/probe_structured_support_matrix.py new file mode 100644 index 000000000..ae7e601a6 --- /dev/null +++ b/scripts/probe_structured_support_matrix.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import sys +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any, Literal + +from fast_agent.agents.agent_types import AgentConfig +from fast_agent.agents.tool_agent import ToolAgent +from fast_agent.core import Core +from fast_agent.llm.model_database import ModelDatabase, ModelParameters +from fast_agent.llm.model_factory import ModelFactory +from fast_agent.llm.provider_types import Provider +from fast_agent.types import RequestParams + +if TYPE_CHECKING: + from fast_agent.llm.request_params import StructuredToolPolicy + +JsonMode = Literal["schema", "object", "none"] + +SCHEMA = { + "type": "object", + "properties": { + "probe_id": {"type": "string"}, + "status": {"type": "string"}, + }, + "required": ["probe_id", "status"], + "additionalProperties": False, +} + +TOOL_SCHEMA = { + "type": "object", + "properties": { + "probe_id": {"type": "string"}, + "magic_number": {"type": "integer"}, + "tool_name": {"type": "string"}, + "summary": {"type": "string"}, + }, + "required": ["probe_id", "magic_number", "tool_name", "summary"], + "additionalProperties": False, +} + + +@dataclass(slots=True) +class ModeProbe: + mode: JsonMode + passed: bool + parsed: dict[str, Any] | None + error: str | None + + +@dataclass(slots=True) +class ToolPolicyProbe: + policy: StructuredToolPolicy + passed: bool + tool_calls: int + final_json_valid: bool + matched_tool_payload: bool + error: str | None + + +@dataclass(slots=True) +class SupportProbeResult: + model: str + resolved_model: str + provider: str + mode_probes: list[ModeProbe] + recommended_json_mode: JsonMode | None + always: ToolPolicyProbe | None + defer: ToolPolicyProbe | None + recommended_policy: str | None + + +def _base_model_name(model_name: str) -> str: + if ":" in model_name: + return model_name.split(":", 1)[0] + return model_name + + +def _mode_value(mode: JsonMode) -> str | None: + return None if mode == "none" else mode + + +def _runtime_params(model_name: str, mode: JsonMode) -> ModelParameters: + base_model = _base_model_name(model_name) + existing = ModelDatabase.get_model_params(base_model, provider=Provider.HUGGINGFACE) + if existing is not None: + return existing.model_copy(update={"json_mode": _mode_value(mode)}) + return ModelParameters( + context_window=262_144, + max_output_tokens=16_384, + tokenizes=list(ModelDatabase.TEXT_ONLY), + json_mode=_mode_value(mode), + default_provider=Provider.HUGGINGFACE, + ) + + +async def _with_mode[T](model_name: str, mode: JsonMode, probe): + base_model = _base_model_name(model_name) + ModelDatabase.register_runtime_model_params(base_model, _runtime_params(model_name, mode)) + try: + return await probe() + finally: + ModelDatabase.unregister_runtime_model_params(base_model) + + +async def _probe_mode(core: Core, model: str, mode: JsonMode) -> ModeProbe: + cfg = ModelFactory.parse_model_string(model) + probe_id = f"mode-{random.SystemRandom().randint(100_000, 999_999)}" + + async def run() -> ModeProbe: + agent = ToolAgent(AgentConfig(name="structured-mode-probe", model=model), [], core.context) + await agent.attach_llm(ModelFactory.create_factory(model)) + try: + parsed, _ = await agent.structured_schema( + f'Return JSON with probe_id="{probe_id}" and status="ok".', + SCHEMA, + RequestParams(use_history=False, maxTokens=768), + ) + passed = parsed == {"probe_id": probe_id, "status": "ok"} + return ModeProbe( + mode=mode, + passed=passed, + parsed=parsed if isinstance(parsed, dict) else None, + error=None if passed else "parsed JSON did not match expected payload", + ) + except Exception as exc: + return ModeProbe(mode=mode, passed=False, parsed=None, error=f"{type(exc).__name__}: {exc}") + finally: + await agent.shutdown() + + return await _with_mode(cfg.model_name, mode, run) + + +async def _probe_policy( + core: Core, + model: str, + mode: JsonMode, + policy: StructuredToolPolicy, +) -> ToolPolicyProbe: + cfg = ModelFactory.parse_model_string(model) + probe_id = f"tool-{random.SystemRandom().randint(100_000, 999_999)}" + magic_number = random.SystemRandom().randint(10_000_000, 99_999_999) + tool_calls = 0 + + async def get_probe_payload() -> dict[str, str | int]: + """Return the current probe payload required for the final structured answer.""" + nonlocal tool_calls + tool_calls += 1 + return { + "probe_id": probe_id, + "magic_number": magic_number, + "tool_name": "get_probe_payload", + } + + async def run() -> ToolPolicyProbe: + agent = ToolAgent( + AgentConfig(name="structured-tools-matrix-probe", model=model), + [get_probe_payload], + core.context, + ) + await agent.attach_llm(ModelFactory.create_factory(model)) + try: + parsed, _ = await agent.structured_schema( + "You must call get_probe_payload before answering. " + "The payload changes every run, so do not guess.", + TOOL_SCHEMA, + RequestParams( + use_history=False, + maxTokens=1024, + max_iterations=4, + structured_tool_policy=policy, + ), + ) + final_json_valid = isinstance(parsed, dict) + matched = ( + final_json_valid + and parsed.get("probe_id") == probe_id + and parsed.get("magic_number") == magic_number + and parsed.get("tool_name") == "get_probe_payload" + ) + passed = tool_calls > 0 and final_json_valid and matched + return ToolPolicyProbe( + policy=policy, + passed=passed, + tool_calls=tool_calls, + final_json_valid=final_json_valid, + matched_tool_payload=matched, + error=None if passed else "tool was not called or final JSON did not match payload", + ) + except Exception as exc: + return ToolPolicyProbe( + policy=policy, + passed=False, + tool_calls=tool_calls, + final_json_valid=False, + matched_tool_payload=False, + error=f"{type(exc).__name__}: {exc}", + ) + finally: + await agent.shutdown() + + return await _with_mode(cfg.model_name, mode, run) + + +async def probe_model(core: Core, model: str) -> SupportProbeResult: + cfg = ModelFactory.parse_model_string(model) + mode_probes: list[ModeProbe] = [] + recommended_mode: JsonMode | None = None + for mode in ("schema", "object", "none"): + result = await _probe_mode(core, model, mode) + mode_probes.append(result) + if result.passed and recommended_mode is None: + recommended_mode = mode + break + + always: ToolPolicyProbe | None = None + defer: ToolPolicyProbe | None = None + recommended_policy: str | None = None + if recommended_mode is not None: + always = await _probe_policy(core, model, recommended_mode, "always") + if always.passed: + recommended_policy = "always" + else: + defer = await _probe_policy(core, model, recommended_mode, "defer") + recommended_policy = "defer" if defer.passed else "no_tools" + + return SupportProbeResult( + model=model, + resolved_model=cfg.model_name, + provider=cfg.provider.config_name, + mode_probes=mode_probes, + recommended_json_mode=recommended_mode, + always=always, + defer=defer, + recommended_policy=recommended_policy, + ) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Probe structured-output support matrix.") + parser.add_argument("--models", required=True, help="Comma-separated model aliases/specs.") + parser.add_argument("--json", action="store_true", help="Emit JSON only.") + return parser.parse_args() + + +def _print_table(results: list[SupportProbeResult]) -> None: + print("| Model | Resolved | Mode | Always | Defer | Recommended |") + print("|---|---|---:|---:|---:|---|") + for result in results: + always = "-" if result.always is None else "pass" if result.always.passed else "fail" + defer = "-" if result.defer is None else "pass" if result.defer.passed else "fail" + print( + f"| `{result.model}` | `{result.resolved_model}` | " + f"`{result.recommended_json_mode}` | {always} | {defer} | " + f"`{result.recommended_policy}` |" + ) + + +async def _run() -> int: + args = _parse_args() + models = [model.strip() for model in args.models.split(",") if model.strip()] + core = Core() + await core.initialize() + try: + results = [await probe_model(core, model) for model in models] + finally: + await core.cleanup() + + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + _print_table(results) + print() + print(json.dumps([asdict(result) for result in results], indent=2)) + return 0 if all(result.recommended_json_mode for result in results) else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(_run())) diff --git a/scripts/probe_tools_structured.py b/scripts/probe_tools_structured.py new file mode 100644 index 000000000..7f7d96379 --- /dev/null +++ b/scripts/probe_tools_structured.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from dataclasses import asdict +from typing import cast + +from fast_agent.cli.checks.structured_tools_probe import ( + StructuredToolPolicy, + _print_text_summary, + run_probe, +) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Probe whether a model can use a tool and still return valid structured JSON " + "through fast-agent's ToolAgent path." + ) + ) + parser.add_argument("models", nargs="+", help="Model ids or aliases to probe.") + parser.add_argument("--json", action="store_true", help="Emit JSON output.") + parser.add_argument( + "--structured-tool-policy", + choices=("auto", "always", "defer", "no_tools"), + default="auto", + ) + return parser.parse_args() + + +async def _run(args: argparse.Namespace) -> int: + results = await run_probe( + args.models, + structured_tool_policy=cast("StructuredToolPolicy", args.structured_tool_policy), + ) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2, sort_keys=True)) + else: + _print_text_summary(results) + return 0 if all(result.passed for result in results) else 1 + + +def main() -> int: + return asyncio.run(_run(_parse_args())) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/shell_runtime_stress.sh b/scripts/shell_runtime_stress.sh new file mode 100755 index 000000000..71017acbd --- /dev/null +++ b/scripts/shell_runtime_stress.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +uv run python - <<'PY' +import asyncio +import logging +import os +import signal +import shlex +import sys +import tempfile +import time +from pathlib import Path + +from mcp.types import TextContent + +from fast_agent.config import Settings, ShellSettings +from fast_agent.tools.shell_runtime import ShellRuntime + + +def text_of(result) -> str: + assert result.content and isinstance(result.content[0], TextContent) + return result.content[0].text + + +def kill_pid_file(path: Path) -> None: + if not path.exists(): + return + try: + pid = int(path.read_text(encoding="utf-8").strip()) + except ValueError: + return + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.kill(pid, sig) + except ProcessLookupError: + return + except OSError: + return + time.sleep(0.1) + + +async def run_case(name: str, coro) -> None: + started = time.monotonic() + print(f"\n== {name}") + await coro() + print(f"ok ({time.monotonic() - started:.2f}s)") + + +async def main() -> None: + logger = logging.getLogger("shell-runtime-stress") + logging.basicConfig(level=logging.WARNING) + base_config = Settings(shell_execution=ShellSettings(show_bash=False)) + + async def inherited_pipe_after_parent_exit() -> None: + if sys.platform.startswith("win"): + print("skipped on Windows") + return + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + pid_path = tmp / "descendant.pid" + script = tmp / "hold_pipe.py" + script.write_text( + "\n".join( + [ + "import subprocess, sys", + "child = subprocess.Popen(", + " [sys.executable, '-c', 'import time; time.sleep(30)'],", + " stdout=sys.stdout,", + " stderr=sys.stderr,", + " start_new_session=True,", + ")", + f"open({str(pid_path)!r}, 'w', encoding='utf-8').write(str(child.pid))", + "print('parent exiting', flush=True)", + ] + ), + encoding="utf-8", + ) + runtime = ShellRuntime( + activation_reason="stress", + logger=logger, + timeout_seconds=10, + config=base_config, + ) + started = time.monotonic() + try: + result = await runtime.execute({"command": f'"{sys.executable}" "{script}"'}) + finally: + kill_pid_file(pid_path) + elapsed = time.monotonic() - started + output = text_of(result) + assert elapsed < 7, elapsed + assert result.isError is False + assert "parent exiting" in output + assert "output collection stopped after" in output + + async def idle_timeout_with_escaped_descendant() -> None: + if sys.platform.startswith("win"): + print("skipped on Windows") + return + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + pid_path = tmp / "descendant.pid" + script = tmp / "idle_timeout.py" + script.write_text( + "\n".join( + [ + "import subprocess, sys, time", + "print('before idle timeout', flush=True)", + "child = subprocess.Popen(", + " [sys.executable, '-c', 'import time; time.sleep(30)'],", + " stdout=sys.stdout,", + " stderr=sys.stderr,", + " start_new_session=True,", + ")", + f"open({str(pid_path)!r}, 'w', encoding='utf-8').write(str(child.pid))", + "time.sleep(30)", + ] + ), + encoding="utf-8", + ) + runtime = ShellRuntime( + activation_reason="stress", + logger=logger, + timeout_seconds=1, + warning_interval_seconds=10, + config=base_config, + ) + started = time.monotonic() + try: + result = await runtime.execute({"command": f'"{sys.executable}" "{script}"'}) + finally: + kill_pid_file(pid_path) + elapsed = time.monotonic() - started + output = text_of(result) + assert elapsed < 8, elapsed + assert result.isError is True + assert "before idle timeout" in output + assert "timeout after 1s" in output + assert "output collection stopped after" in output + + async def huge_output_low_retention_limit() -> None: + runtime = ShellRuntime( + activation_reason="stress", + logger=logger, + timeout_seconds=10, + output_byte_limit=1024, + config=base_config, + ) + code = "import sys; sys.stdout.buffer.write(b'x' * 5_000_000)" + result = await runtime.execute( + {"command": f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"} + ) + output = text_of(result) + assert result.isError is False + assert "process exit code was 0" in output + assert "[Output truncated: showing first" in output + assert len(output.encode("utf-8")) < 5_000 + + async def mixed_stderr_truncation_diagnostic() -> None: + runtime = ShellRuntime( + activation_reason="stress", + logger=logger, + timeout_seconds=10, + output_byte_limit=1024, + config=base_config, + ) + code = ( + "import sys; " + "sys.stdout.write('HEAD\\n' + 'x' * 1000000); " + "sys.stderr.write('STDERR_TAIL_MARKER\\n')" + ) + command = f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}" + result = await runtime.execute({"command": command}) + output = text_of(result) + assert result.isError is False + assert "HEAD" in output + assert "[Output truncated: showing first" in output + print(f"stderr marker retained: {'STDERR_TAIL_MARKER' in output}") + + await run_case("inherited pipe after parent exit", inherited_pipe_after_parent_exit) + await run_case("idle timeout with escaped descendant", idle_timeout_with_escaped_descendant) + await run_case("huge output with low retention limit", huge_output_low_retention_limit) + await run_case("mixed stderr truncation diagnostic", mixed_stderr_truncation_diagnostic) + + +asyncio.run(main()) +PY diff --git a/scripts/test_package_install.sh b/scripts/test_package_install.sh index b828ed60d..ba8bee884 100755 --- a/scripts/test_package_install.sh +++ b/scripts/test_package_install.sh @@ -25,7 +25,7 @@ uv pip install ../../dist/fast_agent_mcp-$VERSION-py3-none-any.whl fast-agent quickstart workflow # Check if workflows folder was created AND contains files -if [ -d "workflow" ] && [ -f "workflow/chaining.py" ] && [ -f "workflow/fastagent.config.yaml" ]; then +if [ -d "workflow" ] && [ -f "workflow/chaining.py" ] && [ -f "workflow/fast-agent.yaml" ]; then echo "✅ Test successful: workflow examples created!" else echo "❌ Test failed: workflow examples not created." @@ -37,7 +37,7 @@ fi # Run the quickstart command fast-agent quickstart state-transfer -if [ -d "state-transfer" ] && [ -f "state-transfer/agent_one.py" ] && [ -f "state-transfer/fastagent.config.yaml" ]; then +if [ -d "state-transfer" ] && [ -f "state-transfer/agent_one.py" ] && [ -f "state-transfer/fast-agent.yaml" ]; then echo "✅ Test successful: state-transfer examples created!" else echo "❌ Test failed: state-transfer examples not created." @@ -50,7 +50,7 @@ fi printf '\n' | fast-agent scaffold --force # Check that setup created the expected files in the current directory -if [ -f "fastagent.config.yaml" ] && [ -f "fastagent.secrets.yaml" ] && [ -f "agent.py" ]; then +if [ -f "fast-agent.yaml" ] && [ -f "fast-agent.secrets.yaml" ] && [ -f "agent.py" ]; then echo "✅ Test successful: setup created config, secrets, and agent.py!" else echo "❌ Test failed: setup did not create expected files." diff --git a/src/fast_agent/acp/server/agent_acp_server.py b/src/fast_agent/acp/server/agent_acp_server.py index b6f99581e..5c564917b 100644 --- a/src/fast_agent/acp/server/agent_acp_server.py +++ b/src/fast_agent/acp/server/agent_acp_server.py @@ -6,6 +6,7 @@ """ import asyncio +import os from importlib.metadata import version as get_version from pathlib import Path from typing import Any, Awaitable, Callable, Sequence, cast @@ -60,8 +61,8 @@ from fast_agent.acp.server.session_store import ACPServerSessionStore, SessionStoreHost from fast_agent.acp.server.slash_runtime import ACPServerSlashRuntime, SlashRuntimeHost from fast_agent.agents.tool_runner import ToolRunnerHooks -from fast_agent.config import MCPServerSettings -from fast_agent.constants import DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT +from fast_agent.config import MCPServerSettings, get_settings +from fast_agent.constants import DEFAULT_ENVIRONMENT_DIR, DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT from fast_agent.core.default_agent import agent_is_default, resolve_default_agent_name from fast_agent.core.exceptions import ProviderKeyError from fast_agent.core.fastagent import AgentInstance @@ -80,7 +81,7 @@ ACP_AUTH_METHOD_ID = "fast-agent-ai-secrets" ACP_AUTH_DOCS_URL = "https://fast-agent.ai/ref/config_file/" -ACP_AUTH_CONFIG_FILE = "fastagent.secrets.yaml" +ACP_AUTH_CONFIG_FILE = "fast-agent.secrets.yaml" ACP_AUTH_RECOMMENDED_COMMANDS: tuple[str, ...] = ( "fast-agent check", "fast-agent model doctor", @@ -368,7 +369,7 @@ async def initialize( id=ACP_AUTH_METHOD_ID, name="Configure fast-agent", description=( - "Set provider keys in fastagent.secrets.yaml or env vars. " + "Set provider keys in fast-agent.secrets.yaml or env vars. " "See docs: [Configuration Reference](https://fast-agent.ai/ref/config_file/)" ), ) @@ -478,6 +479,24 @@ def _resolve_request_cwd( return str(path.resolve()) def _get_session_manager(self, *, cwd: Path | None = None) -> Any: + if cwd is None: + return get_session_manager() + + settings = get_settings() + configured_environment_dir = settings.environment_dir + legacy_environment_dir = os.getenv("ENVIRONMENT_DIR") + ambient_legacy_environment_dir = ( + configured_environment_dir is not None + and legacy_environment_dir is not None + and Path(configured_environment_dir).expanduser() + == Path(legacy_environment_dir).expanduser() + ) + if configured_environment_dir is not None and not ambient_legacy_environment_dir: + return get_session_manager(cwd=cwd, environment_override=configured_environment_dir) + + if settings._fast_agent_home_source == "default": + return get_session_manager(cwd=cwd, environment_override=DEFAULT_ENVIRONMENT_DIR) + return get_session_manager(cwd=cwd) @staticmethod diff --git a/src/fast_agent/acp/server/session_runtime.py b/src/fast_agent/acp/server/session_runtime.py index 3bad98305..402a6fd02 100644 --- a/src/fast_agent/acp/server/session_runtime.py +++ b/src/fast_agent/acp/server/session_runtime.py @@ -615,7 +615,9 @@ def _apply_session_agent_bindings( and isinstance(agent, ShellRuntimeCapable) and agent.shell_runtime_enabled ): - agent.set_external_runtime(session_state.terminal_runtime) + shell_runtime = agent.shell_runtime + if shell_runtime is not None and not shell_runtime.prefer_local_shell: + agent.set_external_runtime(session_state.terminal_runtime) if ( bind_runtimes @@ -868,6 +870,14 @@ async def initialize_session_state( shell_runtime = agent.shell_runtime if shell_runtime is None: continue + if shell_runtime.prefer_local_shell: + logger.info( + "ACP terminal runtime injection skipped; local shell preferred", + name="acp_terminal_local_shell_preferred", + session_id=session_id, + agent_name=agent_name, + ) + continue default_limit = shell_runtime.output_byte_limit perm_handler = session_state.permission_handler terminal_runtime = ACPTerminalRuntime( diff --git a/src/fast_agent/acp/slash_commands.py b/src/fast_agent/acp/slash_commands.py index ebb5de981..3252bf364 100644 --- a/src/fast_agent/acp/slash_commands.py +++ b/src/fast_agent/acp/slash_commands.py @@ -45,20 +45,31 @@ from fast_agent.acp.slash.handlers import skills as skills_slash_handlers from fast_agent.acp.slash.handlers import status as status_slash_handlers from fast_agent.acp.slash.handlers import tools as tools_slash_handlers +from fast_agent.command_actions import ( + PluginCommandActionContext, + PluginCommandActionRegistry, +) +from fast_agent.command_actions.accessors import ( + plugin_command_base_path_for_provider, + plugin_commands_for_agent, + plugin_commands_for_provider, +) from fast_agent.commands.command_catalog import command_action_names from fast_agent.commands.context import CommandContext from fast_agent.commands.handlers import model as model_handlers from fast_agent.commands.protocols import ACPCommandAllowlistProvider from fast_agent.commands.renderers.command_markdown import render_command_outcome_markdown +from fast_agent.commands.results import CommandOutcome from fast_agent.config import get_settings +from fast_agent.core.exceptions import AgentConfigError from fast_agent.core.logging.logger import get_logger from fast_agent.history.history_exporter import HistoryExporter from fast_agent.interfaces import ACPAwareProtocol, AgentProtocol if TYPE_CHECKING: from fast_agent.acp.acp_context import ACPContext + from fast_agent.command_actions.models import PluginCommandAgentProtocol from fast_agent.commands.context import AgentProvider - from fast_agent.commands.results import CommandOutcome from fast_agent.config import MCPServerSettings from fast_agent.core.fastagent import AgentInstance from fast_agent.mcp.mcp_aggregator import MCPAttachOptions @@ -353,6 +364,36 @@ def get_available_commands(self) -> list[AvailableCommand]: AvailableCommand(name=name, description=cmd.description, input=cmd_input) ) + agent_commands = plugin_commands_for_agent(agent) + if agent_commands: + existing_names = {command.name for command in commands} + for name, spec in agent_commands.items(): + if name in existing_names: + continue + cmd_input = None + if spec.input_hint: + cmd_input = AvailableCommandInput( + root=UnstructuredCommandInput(hint=spec.input_hint) + ) + commands.append( + AvailableCommand(name=name, description=spec.description, input=cmd_input) + ) + + global_commands = plugin_commands_for_provider(self.instance.app) + if global_commands: + existing_names = {command.name for command in commands} + for name, spec in global_commands.items(): + if name in existing_names: + continue + cmd_input = None + if spec.input_hint: + cmd_input = AvailableCommandInput( + root=UnstructuredCommandInput(hint=spec.input_hint) + ) + commands.append( + AvailableCommand(name=name, description=spec.description, input=cmd_input) + ) + return commands def _apply_dynamic_session_command_hints( @@ -674,12 +715,87 @@ async def execute_command(self, command_name: str, arguments: str) -> str: if command_name in agent_commands: return await agent_commands[command_name].handler(arguments) + agent_commands = plugin_commands_for_agent(agent) + if agent is not None and agent_commands and command_name in agent_commands: + spec = agent_commands[command_name] + base_path = None + if isinstance(agent, AgentProtocol) and agent.config.source_path: + base_path = agent.config.source_path.parent + return await self._execute_plugin_command_action( + agent, + command_name, + arguments, + spec=spec, + base_path=base_path, + ) + + global_commands = plugin_commands_for_provider(self.instance.app) + if agent is not None and global_commands and command_name in global_commands: + return await self._execute_plugin_command_action( + agent, + command_name, + arguments, + spec=global_commands[command_name], + base_path=plugin_command_base_path_for_provider(self.instance.app), + ) + # Unknown command available = self.get_available_commands() return f"Unknown command: /{command_name}\n\nAvailable commands:\n" + "\n".join( f" /{cmd.name} - {cmd.description}" for cmd in available ) + async def _execute_plugin_command_action( + self, + agent: AgentProtocol, + command_name: str, + arguments: str, + spec, + base_path: Path | None, + ) -> str: + command_context = self._build_command_context() + try: + registry = PluginCommandActionRegistry.from_specs( + {command_name: spec}, + base_path=base_path, + ) + result = await registry.execute( + command_name, + PluginCommandActionContext( + command_name=command_name, + arguments=arguments, + agent=cast("PluginCommandAgentProtocol", agent), + settings=command_context.settings, + session_cwd=command_context.session_cwd, + ), + ) + except AgentConfigError as exc: + return f"Command /{command_name} failed to load: {exc}" + except Exception as exc: # noqa: BLE001 + self._logger.exception("Plugin command action failed", command=command_name) + return f"Command /{command_name} failed: {exc}" + + if result is None: + return "" + + outcome = CommandOutcome( + buffer_prefill=result.buffer_prefill, + switch_agent=result.switch_agent, + requires_refresh=result.refresh_agents, + ) + if result.markdown: + outcome.add_message(result.markdown, render_markdown=True) + elif result.message: + outcome.add_message(result.message) + if result.buffer_prefill: + outcome.add_message( + "Command produced draft text:\n\n```text\n" + f"{result.buffer_prefill}\n" + "```", + render_markdown=True, + ) + return self._format_outcome_as_markdown(outcome, f"/{command_name}") + async def _handle_history(self, arguments: str | None = None) -> str: return await history_slash_handlers.handle_history(self, arguments) diff --git a/src/fast_agent/agents/agent_types.py b/src/fast_agent/agents/agent_types.py index cb8b0357a..450fc8048 100644 --- a/src/fast_agent/agents/agent_types.py +++ b/src/fast_agent/agents/agent_types.py @@ -10,6 +10,7 @@ from mcp.client.session import ElicitationFnT +from fast_agent.command_actions import PluginCommandActionSpec from fast_agent.constants import DEFAULT_AGENT_INSTRUCTION from fast_agent.skills import SKILLS_DEFAULT, SkillManifest, SkillRegistry, SkillsDefault from fast_agent.tools.function_tool_config import FunctionToolSpec @@ -67,6 +68,7 @@ class ScopedFunctionToolConfig: # e.g., {"after_turn_complete": "hooks.py:my_hook"} ToolHooksConfig: TypeAlias = dict[str, str] | None LifecycleHooksConfig: TypeAlias = dict[str, str] | None +PluginCommandsConfig: TypeAlias = dict[str, PluginCommandActionSpec] | None @dataclass(frozen=True, slots=True) @@ -119,6 +121,7 @@ class AgentConfig: cwd: Path | None = None tool_hooks: ToolHooksConfig = None lifecycle_hooks: LifecycleHooksConfig = None + commands: PluginCommandsConfig = None trim_tool_history: bool = False mcp_connect: list[MCPConnectTarget] = field(default_factory=list) source_path: Path | None = field(default=None, repr=False) diff --git a/src/fast_agent/agents/llm_agent.py b/src/fast_agent/agents/llm_agent.py index 48b36d04d..aff9e48e0 100644 --- a/src/fast_agent/agents/llm_agent.py +++ b/src/fast_agent/agents/llm_agent.py @@ -902,7 +902,7 @@ async def structured_schema_impl( list(reversed(trailing_users)), request_params=request_params ) - (result, message), summary = await self._structured_schema_with_summary( + (result, message), summary = await self._structured_schema_via_generate_with_summary( messages, schema, request_params ) summary_text = self._summary_text_for_result(message, summary) diff --git a/src/fast_agent/agents/llm_decorator.py b/src/fast_agent/agents/llm_decorator.py index 3587756f6..8b7fee7f4 100644 --- a/src/fast_agent/agents/llm_decorator.py +++ b/src/fast_agent/agents/llm_decorator.py @@ -68,6 +68,7 @@ ) from fast_agent.llm.provider_types import Provider from fast_agent.llm.stream_types import StreamChunk +from fast_agent.llm.structured_schema import validate_json_schema_definition from fast_agent.llm.usage_tracking import UsageAccumulator from fast_agent.mcp.helpers.content_helpers import normalize_to_extended_list, text_content from fast_agent.mcp.mime_utils import is_text_mime_type @@ -778,7 +779,9 @@ async def structured_schema_impl( """ Implementation method for structured_schema. """ - result, _ = await self._structured_schema_with_summary(messages, schema, request_params) + result, _ = await self._structured_schema_via_generate_with_summary( + messages, schema, request_params + ) return result async def _generate_with_summary( @@ -820,19 +823,26 @@ async def _structured_with_summary( pass return structured_result, call_ctx.summary - async def _structured_schema_with_summary( + async def _structured_schema_via_generate_with_summary( self, messages: list[PromptMessageExtended], schema: dict[str, Any], request_params: RequestParams | None = None, ) -> tuple[tuple[Any | None, PromptMessageExtended], RemovedContentSummary | None]: assert self._llm, "LLM is not attached" + normalized_schema = validate_json_schema_definition(schema) call_ctx = self._prepare_llm_call(messages, request_params) + call_params = (call_ctx.call_params or RequestParams()).model_copy( + update={"structured_schema": normalized_schema} + ) - structured_result = await self._llm.structured_schema( + response = await self._llm.generate( call_ctx.full_history, - schema, - call_ctx.call_params, + call_params, + ) + structured_result = self._llm.parse_structured_schema_response( + response, + normalized_schema, ) if call_ctx.persist_history: diff --git a/src/fast_agent/agents/mcp_agent.py b/src/fast_agent/agents/mcp_agent.py index 640249c96..11992b82e 100644 --- a/src/fast_agent/agents/mcp_agent.py +++ b/src/fast_agent/agents/mcp_agent.py @@ -202,6 +202,7 @@ def __init__( self._skill_manifests: list[SkillManifest] = [] self._skill_map: dict[str, SkillManifest] = {} self._skill_reader: SkillReader | None = None + self._no_shell_requested = bool(context and getattr(context, "no_shell", False)) self.set_skill_manifests(manifests) self.skill_registry: SkillRegistry | None = None if isinstance(self.config.skills, SkillRegistry): @@ -210,8 +211,11 @@ def __init__( self.skill_registry = context.skill_registry self._warnings: list[str] = [] self._warning_messages_seen: set[str] = set() - shell_flag_requested = bool(context and getattr(context, "shell_runtime", False)) - shell_config_requested = bool(self.config.shell) + shell_flag_requested = ( + bool(context and getattr(context, "shell_runtime", False)) + and not self._no_shell_requested + ) + shell_config_requested = bool(self.config.shell) and not self._no_shell_requested skills_configured = bool(self._skill_manifests) self._shell_runtime_activation_reason: str | None = None @@ -220,7 +224,7 @@ def __init__( reasons.append("--shell flag") if shell_config_requested: reasons.append("agent config") - if skills_configured: + if skills_configured and not self._no_shell_requested: reasons.append("agent skills configuration") if reasons: @@ -248,12 +252,13 @@ def __init__( modes.append("switch") self._shell_access_modes = tuple(modes) - self._activate_shell_runtime( - self._shell_runtime_activation_reason, - working_directory=self.config.cwd, - skills_directory=skills_directory, - access_modes=self._shell_access_modes, - ) + if self._shell_runtime_activation_reason is not None: + self._activate_shell_runtime( + self._shell_runtime_activation_reason, + working_directory=self.config.cwd, + skills_directory=skills_directory, + access_modes=self._shell_access_modes, + ) # Store instruction context for template resolution self._instruction_context: dict[str, str] = {} @@ -593,6 +598,8 @@ def set_skill_manifests(self, manifests: Sequence[SkillManifest]) -> None: self._skill_reader = None def _ensure_shell_runtime_for_skills(self) -> None: + if self._no_shell_requested: + return if self._shell_runtime_enabled: return if self._external_runtime is not None: diff --git a/src/fast_agent/agents/tool_agent.py b/src/fast_agent/agents/tool_agent.py index 0413442b1..72ea57682 100644 --- a/src/fast_agent/agents/tool_agent.py +++ b/src/fast_agent/agents/tool_agent.py @@ -23,6 +23,7 @@ from fast_agent.core.prompt import Prompt from fast_agent.event_progress import ProgressAction from fast_agent.interfaces import LlmAgentProtocol, ToolRunnerHookCapable +from fast_agent.llm.structured_schema import validate_json_schema_definition from fast_agent.mcp.helpers.content_helpers import text_content from fast_agent.mcp.tool_execution_handler import ToolExecutionHandler from fast_agent.tools.elicitation import get_elicitation_fastmcp_tool @@ -449,6 +450,22 @@ async def generate_impl( ) return await runner.until_done() + async def structured_schema_impl( + self, + messages: list[PromptMessageExtended], + schema: dict[str, Any], + request_params: RequestParams | None = None, + ) -> tuple[Any | None, PromptMessageExtended]: + """Run raw-schema structured output through the normal tool loop.""" + llm = self._require_llm() + normalized_schema = validate_json_schema_definition(schema) + structured_params = llm.get_request_params(request_params).model_copy( + update={"structured_schema": normalized_schema} + ) + + response = await self.generate_impl(messages, structured_params) + return llm.parse_structured_schema_response(response, normalized_schema) + def _tool_runner_hooks(self) -> ToolRunnerHooks | None: if isinstance(self, ToolRunnerHookCapable): return self.tool_runner_hooks @@ -554,6 +571,39 @@ async def _tool_runner_llm_step( ) -> PromptMessageExtended: return await super().generate_impl(messages, request_params=request_params, tools=tools) + def should_finalize_deferred_structured_turn( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams | None, + tools: list[Tool] | None, + assistant_message: PromptMessageExtended, + ) -> bool: + del assistant_message + if self.llm is None: + return False + final_params = self.llm.get_request_params(request_params) + return ( + final_params.structured_schema is not None + and bool(tools) + and self.llm.resolve_structured_tool_policy(final_params) == "defer" + and not any(message.tool_results for message in messages) + ) + + def should_suppress_tools_for_structured_turn( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams | None, + tools: list[Tool] | None, + ) -> bool: + del messages + if self.llm is None or not tools: + return False + final_params = self.llm.get_request_params(request_params) + return ( + final_params.structured_schema is not None + and self.llm.resolve_structured_tool_policy(final_params) == "no_tools" + ) + def _should_display_user_message(self, message: PromptMessageExtended) -> bool: return not message.tool_results diff --git a/src/fast_agent/agents/tool_runner.py b/src/fast_agent/agents/tool_runner.py index e7c9f9e49..9b57b6dd7 100644 --- a/src/fast_agent/agents/tool_runner.py +++ b/src/fast_agent/agents/tool_runner.py @@ -56,6 +56,21 @@ async def run_tools( async def list_tools(self) -> ListToolsResult: ... + def should_finalize_deferred_structured_turn( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams | None, + tools: list[Tool] | None, + assistant_message: PromptMessageExtended, + ) -> bool: ... + + def should_suppress_tools_for_structured_turn( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams | None, + tools: list[Tool] | None, + ) -> bool: ... + _logger = get_logger(__name__) @@ -142,6 +157,7 @@ def __init__( self._pending_tool_request: PromptMessageExtended | None = None self._pending_tool_response: PromptMessageExtended | None = None self._staged_terminal_response: PromptMessageExtended | None = None + self._deferred_structured_finalization_started = False def _defer_hook_status_messages(self, bucket: str) -> AbstractContextManager[None]: # TODO: Replace this post-hook flush boundary with a first-class @@ -194,10 +210,20 @@ async def __anext__(self) -> PromptMessageExtended: finally: self._flush_deferred_hook_status_messages(_HOOK_STATUS_BUCKET_BEFORE_LLM_CALL) + tools_for_call = ( + [] + if self._agent.should_suppress_tools_for_structured_turn( + self._delta_messages, + self._request_params, + self._tools, + ) + else self._tools + ) + assistant_message = await self._agent._tool_runner_llm_step( self._delta_messages, request_params=self._request_params, - tools=self._tools, + tools=tools_for_call, ) self._last_message = assistant_message @@ -222,6 +248,8 @@ async def __anext__(self) -> PromptMessageExtended: if assistant_message.stop_reason == LlmStopReason.TOOL_USE: self._pending_tool_request = assistant_message self._pending_tool_response = None # Clear cache for new request + elif self._should_start_deferred_structured_finalization(assistant_message): + self._start_deferred_structured_finalization(assistant_message) else: self._done = True @@ -560,6 +588,43 @@ def _stage_tool_response(self, tool_message: PromptMessageExtended) -> None: self._delta_messages.append(self._last_message) self._delta_messages.append(tool_message) + def _should_start_deferred_structured_finalization( + self, + assistant_message: PromptMessageExtended, + ) -> bool: + if self._deferred_structured_finalization_started: + return False + return self._agent.should_finalize_deferred_structured_turn( + self._delta_messages, + self._request_params, + self._tools, + assistant_message, + ) + + def _start_deferred_structured_finalization( + self, + assistant_message: PromptMessageExtended, + ) -> None: + self._deferred_structured_finalization_started = True + finalizer = PromptMessageExtended( + role="user", + content=[ + TextContent( + type="text", + text=( + "Now produce the final answer as structured JSON matching the " + "requested schema. Do not call any tools." + ), + ) + ], + ) + if self._use_history_enabled(): + self._delta_messages = [finalizer] + else: + self._delta_messages.append(assistant_message) + self._delta_messages.append(finalizer) + self._tools = [] + def _consume_staged_terminal_response(self) -> PromptMessageExtended | None: staged = self._staged_terminal_response if staged is None: diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 52dd37571..25f334e5c 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -60,7 +60,7 @@ - Partition them into **child-agent tools** and **regular MCP/local tools**. - Child-agent tools are executed in parallel: - For each child tool call, spawn a detached clone with its own LLM + MCP aggregator and suffixed name. - - Emit `ProgressAction.CHATTING` / `ProgressAction.READY` events for each instance and keep parent status untouched. + - Emit `ProgressAction.SENDING` / `ProgressAction.READY` events for each instance and keep parent status untouched. - Merge each clone's usage back into the template child after shutdown. - Remaining MCP/local tools are delegated to `McpAgent.run_tools()`. - Child and MCP results (and their error text from `FAST_AGENT_ERROR_CHANNEL`) are merged into a single `PromptMessageExtended` that is returned to the parent LLM. @@ -72,14 +72,14 @@ **Before parallel execution:** ``` -▎▶ Chatting ▎ PM-1-DayStatusSummarizer gpt-5 turn 1 +▎▶ Sending ▎ PM-1-DayStatusSummarizer gpt-5 turn 1 ``` **During parallel execution (2+ instances):** - Parent line stays in whatever lifecycle state it already had; no forced "Ready" flips. - New lines appear for each detached instance with suffixed names: ``` -▎▶ Chatting ▎ PM-1-DayStatusSummarizer[1] gpt-5 turn 2 +▎▶ Sending ▎ PM-1-DayStatusSummarizer[1] gpt-5 turn 2 ▎▶ Calling tool ▎ PM-1-DayStatusSummarizer[2] tg-ro (list_messages) ``` @@ -414,7 +414,9 @@ def _default_child_tool_schema() -> dict[str, Any]: @staticmethod def _child_tool_result_mode(child: LlmAgent) -> ToolResultMode: config = getattr(child, "config", None) - request_params = getattr(config, "default_request_params", None) if config is not None else None + request_params = ( + getattr(config, "default_request_params", None) if config is not None else None + ) if request_params is None: return "postprocess" return request_params.tool_result_mode @@ -449,7 +451,9 @@ def _configured_child_tool_schema(child: LlmAgent) -> dict[str, Any] | None: @classmethod def _resolved_child_tool_schema(cls, child: LlmAgent) -> dict[str, Any]: configured_schema = cls._configured_child_tool_schema(child) - schema = configured_schema if configured_schema is not None else cls._default_child_tool_schema() + schema = ( + configured_schema if configured_schema is not None else cls._default_child_tool_schema() + ) if cls._child_response_mode_enabled(child): return cls._augment_schema_with_response_mode(schema) return schema @@ -563,9 +567,7 @@ def _child_display_suppressed(self, child: LlmAgent): if original_config is not None and hasattr(child, "display") and child.display: child.display.config = original_config - async def _merge_history( - self, target: LlmAgent, clone: LlmAgent, start_index: int - ) -> None: + async def _merge_history(self, target: LlmAgent, clone: LlmAgent, start_index: int) -> None: """Append clone history from start_index into target with a global merge lock.""" async with self._history_merge_lock: new_messages = clone.message_history[start_index:] @@ -669,9 +671,7 @@ async def emit_progress(label: str | None = None) -> None: before_tool_call = previous_hooks.before_tool_call if previous_hooks else None after_llm_call = previous_hooks.after_llm_call if previous_hooks else None after_tool_call = previous_hooks.after_tool_call if previous_hooks else None - after_turn_complete = ( - previous_hooks.after_turn_complete if previous_hooks else None - ) + after_turn_complete = previous_hooks.after_turn_complete if previous_hooks else None async def handle_before_llm_call(runner, messages): if before_llm_call: @@ -694,9 +694,7 @@ async def handle_before_tool_call(runner, message): try: scope = ( - acp_tool_call_context( - parent_tool_call_id=tool_call_id - ) + acp_tool_call_context(parent_tool_call_id=tool_call_id) if tool_handler and tool_call_id else acp_tool_call_context() ) @@ -759,9 +757,7 @@ async def handle_before_tool_call(runner, message): if tool_handler and tool_call_id: try: with acp_tool_call_context(): - await tool_handler.on_tool_complete( - tool_call_id, False, None, str(exc) - ) + await tool_handler.on_tool_complete(tool_call_id, False, None, str(exc)) except Exception: pass return CallToolResult(content=[text_content(f"Error: {exc}")], isError=True) @@ -798,9 +794,7 @@ async def call_tool( request_params=request_params, ) - return await super().call_tool( - name, arguments, tool_use_id, request_params=request_params - ) + return await super().call_tool(name, arguments, tool_use_id, request_params=request_params) def _show_parallel_tool_calls( self, @@ -1088,7 +1082,7 @@ async def call_with_instance_name( try: outer_progress_display.update( ProgressEvent( - action=ProgressAction.CHATTING, + action=ProgressAction.SENDING, target=instance_name, details="", agent_name=instance_name, @@ -1137,9 +1131,7 @@ async def call_with_instance_name( ) elif history_merge_target == HistoryMergeTarget.CHILD: try: - await self._merge_history( - target=child, clone=clone, start_index=fork_index - ) + await self._merge_history(target=child, clone=clone, start_index=fork_index) except Exception as merge_hist_exc: logger.warning( "Failed to merge child history", @@ -1150,9 +1142,7 @@ async def call_with_instance_name( ) elif history_merge_target == HistoryMergeTarget.ORCHESTRATOR: try: - await self._merge_history( - target=self, clone=clone, start_index=fork_index - ) + await self._merge_history(target=self, clone=clone, start_index=fork_index) except Exception as merge_hist_exc: logger.warning( "Failed to merge orchestrator history", diff --git a/src/fast_agent/batch/__init__.py b/src/fast_agent/batch/__init__.py new file mode 100644 index 000000000..1893c9986 --- /dev/null +++ b/src/fast_agent/batch/__init__.py @@ -0,0 +1,2 @@ +"""Batch processing helpers for fast-agent.""" + diff --git a/src/fast_agent/batch/input.py b/src/fast_agent/batch/input.py new file mode 100644 index 000000000..1f74d3c8d --- /dev/null +++ b/src/fast_agent/batch/input.py @@ -0,0 +1,98 @@ +"""Input row loading and selection for structured batch runs.""" + +from __future__ import annotations + +import csv +import json +import random +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Iterable + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass(frozen=True) +class RowError: + type: str + message: str + + +@dataclass(frozen=True) +class RowCandidate: + row_number: int + row: dict[str, Any] | None + error: RowError | None = None + + +def iter_jsonl_rows(path: Path) -> Iterable[RowCandidate]: + """Yield JSON object rows, preserving invalid lines as row-error candidates.""" + with path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError as exc: + yield RowCandidate( + row_number=line_number, + row=None, + error=RowError("InvalidJSON", f"Line {line_number}: {exc.msg}"), + ) + continue + + if not isinstance(payload, dict): + yield RowCandidate( + row_number=line_number, + row=None, + error=RowError( + "InvalidRow", + f"Line {line_number}: expected a JSON object, got {type(payload).__name__}", + ), + ) + continue + + yield RowCandidate(row_number=line_number, row=payload) + + +def iter_csv_rows(path: Path) -> Iterable[RowCandidate]: + """Yield CSV rows as dictionaries keyed by header name.""" + with path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + for row_number, row in enumerate(reader, start=1): + yield RowCandidate(row_number=row_number, row=dict(row)) + + +def iter_input_rows(path: Path) -> Iterable[RowCandidate]: + suffix = path.suffix.lower() + if suffix == ".jsonl": + return iter_jsonl_rows(path) + if suffix == ".csv": + return iter_csv_rows(path) + raise ValueError(f"Unsupported input format for {path}; expected .jsonl or .csv") + + +def select_rows( + rows: Iterable[RowCandidate], + *, + offset: int | None = None, + sample: int | None = None, + seed: int | None = None, + limit: int | None = None, +) -> list[RowCandidate]: + """Apply offset, deterministic sample, input-order restoration, and limit.""" + candidates = list(rows) + if offset is not None and offset > 0: + candidates = candidates[offset:] + + if sample is not None: + if sample < len(candidates): + rng = random.Random(0 if seed is None else seed) + indexed = list(enumerate(candidates)) + sampled = rng.sample(indexed, sample) + candidates = [candidate for _, candidate in sorted(sampled, key=lambda item: item[0])] + + if limit is not None: + candidates = candidates[:limit] + + return candidates diff --git a/src/fast_agent/batch/output.py b/src/fast_agent/batch/output.py new file mode 100644 index 000000000..12f21a42b --- /dev/null +++ b/src/fast_agent/batch/output.py @@ -0,0 +1,65 @@ +"""JSONL output envelope helpers for structured batch runs.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, TextIO + +if TYPE_CHECKING: + from pathlib import Path + + from fast_agent.batch.input import RowError + + +def success_envelope( + *, + identity: str | int, + row_number: int, + result: Any, + row: dict[str, Any] | None, + include_input: bool, +) -> dict[str, Any]: + envelope: dict[str, Any] = { + "id": identity, + "row_number": row_number, + "ok": True, + "result": result, + "error": None, + } + if include_input: + envelope["input"] = row + return envelope + + +def error_envelope( + *, + identity: str | int, + row_number: int, + error: RowError, + row: dict[str, Any] | None, + include_input: bool, +) -> dict[str, Any]: + envelope: dict[str, Any] = { + "id": identity, + "row_number": row_number, + "ok": False, + "result": None, + "error": { + "type": error.type, + "message": error.message, + }, + } + if include_input: + envelope["input"] = row + return envelope + + +def ensure_parent(path: Path) -> None: + parent = path.parent + if str(parent): + parent.mkdir(parents=True, exist_ok=True) + + +def write_jsonl_record(handle: TextIO, record: dict[str, Any]) -> None: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + handle.flush() diff --git a/src/fast_agent/batch/resume.py b/src/fast_agent/batch/resume.py new file mode 100644 index 000000000..7fda795d9 --- /dev/null +++ b/src/fast_agent/batch/resume.py @@ -0,0 +1,30 @@ +"""Resume-state helpers for structured batch runs.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + +def load_completed_ids(path: Path) -> set[str]: + """Load IDs for existing successful output records.""" + completed: set[str] = set() + if not path.exists(): + return completed + + with path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSONL in existing output at line {line_number}: {exc}") from exc + if not isinstance(record, dict): + raise ValueError(f"Invalid existing output at line {line_number}: expected object") + if record.get("ok") is True and "id" in record: + completed.add(str(record["id"])) + return completed diff --git a/src/fast_agent/batch/structured.py b/src/fast_agent/batch/structured.py new file mode 100644 index 000000000..7d720de1b --- /dev/null +++ b/src/fast_agent/batch/structured.py @@ -0,0 +1,424 @@ +"""Direct-mode structured batch runner.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, TextIO, TypeAlias, cast + +from pydantic import BaseModel + +from fast_agent.batch.input import RowCandidate, RowError, iter_input_rows, select_rows +from fast_agent.batch.output import ( + ensure_parent, + error_envelope, + success_envelope, + write_jsonl_record, +) +from fast_agent.batch.resume import load_completed_ids +from fast_agent.batch.summary import BatchSummary +from fast_agent.batch.template import DEFAULT_ROW_TEMPLATE, render_row_template +from fast_agent.cli.runtime.request_builders import resolve_default_instruction +from fast_agent.constants import FAST_AGENT_TIMING +from fast_agent.llm.request_params import RequestParams +from fast_agent.llm.structured_schema import ( + StructuredSchemaSource, + load_json_schema_file, + load_pydantic_model, +) +from fast_agent.mcp.helpers.content_helpers import get_text + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass(frozen=True) +class StructuredBatchOptions: + input_path: Path + output_path: Path + schema_path: Path | None = None + schema_model: str | None = None + template_path: Path | None = None + instruction_path: Path | None = None + model: str | None = None + include_input: bool = False + limit: int | None = None + offset: int | None = None + sample: int | None = None + seed: int | None = None + resume: bool = False + overwrite: bool = False + id_field: str | None = None + max_errors: int | None = None + error_output_path: Path | None = None + telemetry_output_path: Path | None = None + summary_output_path: Path | None = None + final_summary: bool = True + environment_dir: Path | None = None + shell_runtime: bool = False + + +SchemaSource: TypeAlias = StructuredSchemaSource + + +def utc_now_iso() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def load_json_schema(path: Path) -> dict[str, Any]: + return load_json_schema_file(path) + + +def load_schema_source(options: StructuredBatchOptions) -> SchemaSource: + if options.schema_path is not None and options.schema_model is not None: + raise ValueError("--schema and --schema-model cannot be used together") + if options.schema_path is None and options.schema_model is None: + raise ValueError("One of --schema or --schema-model is required") + if options.schema_model is not None: + return load_pydantic_model(options.schema_model) + assert options.schema_path is not None + return load_json_schema(options.schema_path) + + +def load_text_file(path: Path, label: str) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"Could not read {label} file {path}: {exc}") from exc + + +def _identity_for_candidate(candidate: RowCandidate, id_field: str | None) -> tuple[str | int, RowError | None]: + if id_field is None: + return candidate.row_number, None + row = candidate.row + if row is None: + return candidate.row_number, None + if id_field not in row: + return candidate.row_number, RowError( + "MissingIdField", + f"Missing id field '{id_field}'", + ) + return str(row[id_field]), None + + +def _extract_timing(response: Any) -> dict[str, Any] | None: + channels = response.channels + if not isinstance(channels, Mapping): + return None + timing_blocks = channels.get(FAST_AGENT_TIMING) + if not timing_blocks: + return None + timing_text = get_text(timing_blocks[0]) + if not timing_text: + return None + try: + timing = json.loads(timing_text) + except json.JSONDecodeError: + return None + return timing if isinstance(timing, dict) else None + + +def _write_optional_failure( + error_handle: TextIO | None, + record: dict[str, Any], +) -> None: + if error_handle is not None: + write_jsonl_record(error_handle, record) + + +def _write_optional_telemetry( + telemetry_handle: TextIO | None, + *, + identity: str | int, + row_number: int, + ok: bool, + timing: dict[str, Any] | None, +) -> None: + if telemetry_handle is None: + return + write_jsonl_record( + telemetry_handle, + { + "id": identity, + "row_number": row_number, + "ok": ok, + "timing": timing or {}, + "usage": {}, + }, + ) + + +def _prepare_output_files(options: StructuredBatchOptions) -> None: + if options.resume and options.overwrite: + raise ValueError("--resume and --overwrite cannot be used together") + _reject_duplicate_output_paths(options) + if options.output_path.exists() and not options.resume and not options.overwrite: + raise ValueError( + f"Output file {options.output_path} already exists; use --resume or --overwrite" + ) + + for path in ( + options.output_path, + options.error_output_path, + options.telemetry_output_path, + options.summary_output_path, + ): + if path is not None: + ensure_parent(path) + + +def _reject_duplicate_output_paths(options: StructuredBatchOptions) -> None: + configured_paths = { + "--output": options.output_path, + "--error-output": options.error_output_path, + "--telemetry-output": options.telemetry_output_path, + "--summary-output": options.summary_output_path, + } + resolved_paths: dict[Path, str] = {} + for label, path in configured_paths.items(): + if path is None: + continue + resolved = path.resolve(strict=False) + existing_label = resolved_paths.get(resolved) + if existing_label is not None: + raise ValueError( + f"{label} must not point to the same file as {existing_label}: {path}" + ) + resolved_paths[resolved] = label + + +async def run_structured_batch(options: StructuredBatchOptions) -> dict[str, Any]: + """Run a direct-mode structured batch job and return the summary payload.""" + _prepare_output_files(options) + + schema_source = load_schema_source(options) + template = ( + load_text_file(options.template_path, "template") + if options.template_path is not None + else DEFAULT_ROW_TEMPLATE + ) + instruction = ( + load_text_file(options.instruction_path, "instruction") + if options.instruction_path is not None + else resolve_default_instruction(options.model, "interactive") + ) + + all_candidates = list(iter_input_rows(options.input_path)) + selected = select_rows( + all_candidates, + offset=options.offset, + sample=options.sample, + seed=options.seed, + limit=options.limit, + ) + completed_ids = load_completed_ids(options.output_path) if options.resume else set() + + started_at = utc_now_iso() + summary = BatchSummary( + input_rows=len(all_candidates), + selected_rows=len(selected), + started_at=started_at, + metadata={ + "model": options.model, + "input": str(options.input_path), + "output": str(options.output_path), + "schema": str(options.schema_path) if options.schema_path is not None else None, + "schema_model": options.schema_model, + "instruction": str(options.instruction_path) if options.instruction_path else None, + "template": str(options.template_path) if options.template_path else "", + "shell_runtime": options.shell_runtime, + }, + ) + + from fast_agent import FastAgent + + fast = FastAgent( + name="structured batch", + parse_cli_args=False, + ignore_unknown_args=True, + quiet=True, + environment_dir=options.environment_dir, + ) + if options.model: + fast.args.model = options.model + + @fast.agent(name="batch_worker", instruction=instruction, model=options.model, default=True) + async def batch_worker() -> None: + pass + + if options.shell_runtime: + await fast.app.initialize() + setattr(fast.app.context, "shell_runtime", True) + + output_mode = "a" if options.resume else "w" + if options.overwrite: + output_mode = "w" + + async with fast.run() as agent_app: + worker = agent_app._agent("batch_worker") + with options.output_path.open(output_mode, encoding="utf-8") as output_handle: + with _optional_jsonl_handle(options.error_output_path, "a" if options.resume else "w") as error_handle: + with _optional_jsonl_handle( + options.telemetry_output_path, + "a" if options.resume else "w", + ) as telemetry_handle: + for candidate in selected: + if _max_errors_reached(summary.failed_rows, options.max_errors): + break + identity, id_error = _identity_for_candidate(candidate, options.id_field) + if str(identity) in completed_ids: + summary.skipped_rows += 1 + continue + + row_error = candidate.error or id_error + if row_error is None and candidate.row is not None: + rendered, template_error = render_row_template(template, candidate.row) + row_error = template_error + else: + rendered = None + + if row_error is not None: + record = error_envelope( + identity=identity, + row_number=candidate.row_number, + error=row_error, + row=candidate.row, + include_input=options.include_input, + ) + write_jsonl_record(output_handle, record) + _write_optional_failure(error_handle, record) + _write_optional_telemetry( + telemetry_handle, + identity=identity, + row_number=candidate.row_number, + ok=False, + timing=None, + ) + summary.processed_rows += 1 + summary.failed_rows += 1 + if _max_errors_reached(summary.failed_rows, options.max_errors): + break + continue + + assert rendered is not None + assert candidate.row is not None + try: + parsed, response = await _structured_row_call( + worker, + rendered=rendered, + schema_source=schema_source, + ) + timing = _extract_timing(response) + summary.add_timing(timing) + if parsed is None: + record = error_envelope( + identity=identity, + row_number=candidate.row_number, + error=RowError( + "StructuredOutputError", + "Model response did not satisfy the JSON schema", + ), + row=candidate.row, + include_input=options.include_input, + ) + write_jsonl_record(output_handle, record) + _write_optional_failure(error_handle, record) + _write_optional_telemetry( + telemetry_handle, + identity=identity, + row_number=candidate.row_number, + ok=False, + timing=timing, + ) + summary.processed_rows += 1 + summary.failed_rows += 1 + if _max_errors_reached(summary.failed_rows, options.max_errors): + break + continue + record = success_envelope( + identity=identity, + row_number=candidate.row_number, + result=_json_result(parsed), + row=candidate.row, + include_input=options.include_input, + ) + write_jsonl_record(output_handle, record) + _write_optional_telemetry( + telemetry_handle, + identity=identity, + row_number=candidate.row_number, + ok=True, + timing=timing, + ) + summary.processed_rows += 1 + except Exception as exc: + record = error_envelope( + identity=identity, + row_number=candidate.row_number, + error=RowError(type(exc).__name__, str(exc)), + row=candidate.row, + include_input=options.include_input, + ) + write_jsonl_record(output_handle, record) + _write_optional_failure(error_handle, record) + _write_optional_telemetry( + telemetry_handle, + identity=identity, + row_number=candidate.row_number, + ok=False, + timing=None, + ) + summary.processed_rows += 1 + summary.failed_rows += 1 + if _max_errors_reached(summary.failed_rows, options.max_errors): + break + + completed_at = utc_now_iso() + payload = summary.to_dict(completed_at) + if options.summary_output_path is not None: + options.summary_output_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return payload + + +async def _structured_row_call( + worker: Any, + *, + rendered: str, + schema_source: SchemaSource, +) -> tuple[Any | None, Any]: + request_params = RequestParams(use_history=False) + if isinstance(schema_source, type) and issubclass(schema_source, BaseModel): + return await worker.structured(rendered, schema_source, request_params) + return await worker.structured_schema(rendered, schema_source, request_params) + + +def _json_result(parsed: Any) -> Any: + if isinstance(parsed, BaseModel): + return parsed.model_dump(mode="json") + return parsed + + +def _max_errors_reached(failed_rows: int, max_errors: int | None) -> bool: + return max_errors is not None and failed_rows >= max_errors + + +class _optional_jsonl_handle: + def __init__(self, path: Path | None, mode: str) -> None: + self._path = path + self._mode = mode + self._handle: TextIO | None = None + + def __enter__(self) -> TextIO | None: + if self._path is None: + return None + self._handle = cast("TextIO", self._path.open(self._mode, encoding="utf-8")) + return self._handle + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + if self._handle is not None: + self._handle.close() diff --git a/src/fast_agent/batch/summary.py b/src/fast_agent/batch/summary.py new file mode 100644 index 000000000..c9c7f1c69 --- /dev/null +++ b/src/fast_agent/batch/summary.py @@ -0,0 +1,67 @@ +"""Run summary aggregation for structured batch runs.""" + +from __future__ import annotations + +import statistics +import time +from dataclasses import dataclass, field +from typing import Any + + +def _stats(values: list[float]) -> dict[str, float | int]: + if not values: + return {"count": 0} + return { + "count": len(values), + "min": min(values), + "mean": statistics.fmean(values), + "median": statistics.median(values), + "max": max(values), + } + + +@dataclass +class BatchSummary: + input_rows: int + selected_rows: int + started_at: str + metadata: dict[str, Any] + processed_rows: int = 0 + skipped_rows: int = 0 + failed_rows: int = 0 + timing_duration_ms: list[float] = field(default_factory=list) + timing_ttft_ms: list[float] = field(default_factory=list) + timing_time_to_response_ms: list[float] = field(default_factory=list) + started_monotonic: float = field(default_factory=time.monotonic) + + def add_timing(self, timing: dict[str, Any] | None) -> None: + if not timing: + return + duration = timing.get("duration_ms") + if isinstance(duration, int | float): + self.timing_duration_ms.append(float(duration)) + ttft = timing.get("ttft_ms") + if isinstance(ttft, int | float): + self.timing_ttft_ms.append(float(ttft)) + time_to_response = timing.get("time_to_response_ms") + if isinstance(time_to_response, int | float): + self.timing_time_to_response_ms.append(float(time_to_response)) + + def to_dict(self, completed_at: str) -> dict[str, Any]: + return { + **self.metadata, + "started_at": self.started_at, + "completed_at": completed_at, + "input_rows": self.input_rows, + "selected_rows": self.selected_rows, + "processed_rows": self.processed_rows, + "skipped_rows": self.skipped_rows, + "failed_rows": self.failed_rows, + "duration_ms": round((time.monotonic() - self.started_monotonic) * 1000, 2), + "timing_ms": { + "duration": _stats(self.timing_duration_ms), + "ttft": _stats(self.timing_ttft_ms), + "time_to_response": _stats(self.timing_time_to_response_ms), + }, + } + diff --git a/src/fast_agent/batch/template.py b/src/fast_agent/batch/template.py new file mode 100644 index 000000000..732f0ea50 --- /dev/null +++ b/src/fast_agent/batch/template.py @@ -0,0 +1,36 @@ +"""Tiny row template renderer for structured batch runs.""" + +from __future__ import annotations + +import json +import re +from typing import Any, Final + +from fast_agent.batch.input import RowError + +DEFAULT_ROW_TEMPLATE: Final[str] = "Input record:\n\n{{row_json}}\n" +_PLACEHOLDER_RE: Final[re.Pattern[str]] = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_]*|row_json)\s*}}") + + +def render_row_template(template: str, row: dict[str, Any]) -> tuple[str | None, RowError | None]: + """Render supported placeholders against a top-level row dictionary.""" + + def replace(match: re.Match[str]) -> str: + field_name = match.group(1) + if field_name == "row_json": + return json.dumps(row, ensure_ascii=False, indent=2) + if field_name not in row: + missing_fields.append(field_name) + return "" + value = row[field_name] + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False) + + missing_fields: list[str] = [] + rendered = _PLACEHOLDER_RE.sub(replace, template) + if missing_fields: + names = ", ".join(dict.fromkeys(missing_fields)) + return None, RowError("MissingTemplateField", f"Missing template field(s): {names}") + return rendered, None + diff --git a/src/fast_agent/cli/__main__.py b/src/fast_agent/cli/__main__.py index f77fd793c..7cf9d3248 100644 --- a/src/fast_agent/cli/__main__.py +++ b/src/fast_agent/cli/__main__.py @@ -17,6 +17,7 @@ "--noenv", "--no-env", "--shell", + "--no-shell", "--watch", "--reload", "--smart", diff --git a/src/fast_agent/cli/checks/__init__.py b/src/fast_agent/cli/checks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fast_agent/cli/checks/structured_tools_probe.py b/src/fast_agent/cli/checks/structured_tools_probe.py new file mode 100644 index 000000000..634f85544 --- /dev/null +++ b/src/fast_agent/cli/checks/structured_tools_probe.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +import random +from contextlib import suppress +from dataclasses import dataclass +from typing import Any, Literal + +from fast_agent.agents.agent_types import AgentConfig +from fast_agent.agents.tool_agent import ToolAgent +from fast_agent.core import Core +from fast_agent.llm.model_factory import ModelFactory +from fast_agent.llm.request_params import RequestParams +from fast_agent.llm.structured_schema import ( + validate_json_instance, + validate_json_schema_definition, +) + +StructuredToolPolicy = Literal["auto", "always", "defer", "no_tools"] + +PROBE_SCHEMA = validate_json_schema_definition( + { + "type": "object", + "properties": { + "probe_id": {"type": "string"}, + "magic_number": {"type": "integer"}, + "tool_name": {"type": "string"}, + "summary": {"type": "string"}, + }, + "required": ["probe_id", "magic_number", "tool_name", "summary"], + "additionalProperties": False, + } +) + + +@dataclass(slots=True) +class ProbeResult: + model: str + resolved_model: str | None + provider: str | None + json_mode: str | None + structured_tool_policy: StructuredToolPolicy + passed: bool + tool_calls: int + final_json_valid: bool + matched_tool_payload: bool + stop_reason: str | None + response_text: str | None + parsed: dict[str, Any] | None + error: str | None = None + + +def _build_prompt() -> str: + return ( + "You must call the `get_probe_payload` tool before answering. " + "The payload changes every run, so do not guess. " + # "After the tool result arrives, return only JSON that matches the required schema." + ) + + +async def _probe_model( + core: Core, + model: str, + *, + structured_tool_policy: StructuredToolPolicy, +) -> ProbeResult: + probe_id = f"probe-{random.SystemRandom().randint(100_000, 999_999)}" + magic_number = random.SystemRandom().randint(10_000_000, 99_999_999) + tool_call_count = 0 + + async def get_probe_payload() -> dict[str, str | int]: + """Return the current probe payload required for the final structured answer. + + Call this tool to obtain the authoritative probe_id and magic_number for + this run. Do not guess or invent these values. + """ + nonlocal tool_call_count + tool_call_count += 1 + return { + "probe_id": probe_id, + "magic_number": magic_number, + "tool_name": "get_probe_payload", + } + + agent = ToolAgent( + AgentConfig(name="tools-structured-probe", model=model), + tools=[get_probe_payload], + context=core.context, + ) + await agent.attach_llm(ModelFactory.create_factory(model)) + + request_params = RequestParams( + use_history=False, + structured_schema=PROBE_SCHEMA, + structured_tool_policy=structured_tool_policy, + maxTokens=1024, + max_iterations=4, + ) + + try: + response = await agent.generate(_build_prompt(), request_params=request_params) + resolved_model = agent.llm.resolved_model if agent.llm is not None else None + response_text = response.last_text() + if response_text is None: + raise ValueError("assistant response did not include text content") + + parsed = json.loads(response_text) + if not isinstance(parsed, dict): + raise ValueError(f"structured response was not a JSON object: {type(parsed).__name__}") + + validate_json_instance(parsed, PROBE_SCHEMA) + + if tool_call_count < 1: + raise ValueError("tool was not called") + if parsed.get("probe_id") != probe_id: + raise ValueError("probe_id did not match the tool result") + if parsed.get("magic_number") != magic_number: + raise ValueError("magic_number did not match the tool result") + if parsed.get("tool_name") != "get_probe_payload": + raise ValueError("tool_name did not match the expected tool") + + provider = agent.llm.provider.config_name if agent.llm is not None else None + stop_reason = response.stop_reason.value if response.stop_reason is not None else None + return ProbeResult( + model=model, + resolved_model=resolved_model.wire_model_name if resolved_model is not None else None, + provider=provider, + json_mode=resolved_model.json_mode if resolved_model is not None else None, + structured_tool_policy=structured_tool_policy, + passed=True, + tool_calls=tool_call_count, + final_json_valid=True, + matched_tool_payload=True, + stop_reason=stop_reason, + response_text=response_text, + parsed=parsed, + ) + except Exception as exc: + provider = agent.llm.provider.config_name if agent.llm is not None else None + resolved_model = agent.llm.resolved_model if agent.llm is not None else None + return ProbeResult( + model=model, + resolved_model=resolved_model.wire_model_name if resolved_model is not None else None, + provider=provider, + json_mode=resolved_model.json_mode if resolved_model is not None else None, + structured_tool_policy=structured_tool_policy, + passed=False, + tool_calls=tool_call_count, + final_json_valid=False, + matched_tool_payload=False, + stop_reason=None, + response_text=None, + parsed=None, + error=str(exc), + ) + finally: + with suppress(Exception): + await agent.shutdown() + +def _print_text_summary(results: list[ProbeResult]) -> None: + for result in results: + status = "PASS" if result.passed else "FAIL" + provider = result.provider or "unknown" + details = f"tool_calls={result.tool_calls} stop_reason={result.stop_reason or '-'}" + print(f"{status:4} {result.model:28} provider={provider:18} {details}") + if result.error: + print(f" error: {result.error}") + + passed = sum(1 for result in results if result.passed) + print(f"\nSummary: {passed}/{len(results)} passed") + + +async def run_probe( + models: list[str], + *, + structured_tool_policy: StructuredToolPolicy, +) -> list[ProbeResult]: + core = Core() + await core.initialize() + try: + return [ + await _probe_model( + core, + model, + structured_tool_policy=structured_tool_policy, + ) + for model in models + ] + finally: + await core.cleanup() diff --git a/src/fast_agent/cli/command_support.py b/src/fast_agent/cli/command_support.py index 891c630f1..f44db8c17 100644 --- a/src/fast_agent/cli/command_support.py +++ b/src/fast_agent/cli/command_support.py @@ -53,7 +53,12 @@ def resolve_context_path_option( return None -def get_settings_or_exit(config_path: str | Path | None = None) -> "Settings": +def get_settings_or_exit( + config_path: str | Path | None = None, + *, + env_dir: str | Path | None = None, + noenv: bool = False, +) -> "Settings": """Load settings or exit with a concise user-facing error.""" import typer @@ -61,7 +66,7 @@ def get_settings_or_exit(config_path: str | Path | None = None) -> "Settings": from fast_agent.core.exceptions import FastAgentError, format_fast_agent_error try: - return get_settings(config_path) + return get_settings(config_path, env_dir=env_dir, noenv=noenv) except FastAgentError as exc: typer.echo(f"Error loading fast-agent settings: {format_fast_agent_error(exc)}", err=True) raise typer.Exit(1) from exc diff --git a/src/fast_agent/cli/commands/acp.py b/src/fast_agent/cli/commands/acp.py index 808ef471e..8ab67f07d 100644 --- a/src/fast_agent/cli/commands/acp.py +++ b/src/fast_agent/cli/commands/acp.py @@ -61,7 +61,9 @@ def _build_run_request( resume: str | None, reload: bool, watch: bool, + prefer_local_shell: bool = False, missing_shell_cwd: serve.MissingShellCwdPolicy | None = None, + no_shell: bool = False, ) -> AgentRunRequest: resolved_env_dir = resolve_environment_dir_option(ctx, env_dir, set_env_var=not noenv) return build_command_run_request( @@ -88,6 +90,8 @@ def _build_run_request( noenv=noenv, force_smart=force_smart, shell_enabled=shell, + no_shell=no_shell, + prefer_local_shell=prefer_local_shell, mode="serve", transport=serve.ServeTransport.ACP.value, host=host, @@ -139,6 +143,15 @@ def run_acp( help="Description used for the exposed send tool (use {agent} to reference the agent name)", ), shell: bool = CommonAgentOptions.shell(), + no_shell: bool = CommonAgentOptions.no_shell(), + prefer_local_shell: bool = typer.Option( + False, + "--prefer-local-shell", + help=( + "In ACP shell mode, use fast-agent's local shell runtime instead of the " + "ACP client's terminal capability" + ), + ), no_permissions: bool = typer.Option( False, "--no-permissions", @@ -181,6 +194,8 @@ def run_acp( host=host, port=port, shell=shell, + no_shell=no_shell, + prefer_local_shell=prefer_local_shell, no_permissions=no_permissions, resume=resume, reload=reload, diff --git a/src/fast_agent/cli/commands/batch.py b/src/fast_agent/cli/commands/batch.py new file mode 100644 index 000000000..d6d0bff54 --- /dev/null +++ b/src/fast_agent/cli/commands/batch.py @@ -0,0 +1,151 @@ +"""Batch processing commands.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import typer + +from fast_agent.batch.structured import StructuredBatchOptions, run_structured_batch +from fast_agent.cli.command_support import ensure_context_object +from fast_agent.cli.shared_options import CommonAgentOptions +from fast_agent.utils.async_utils import configure_uvloop + +app = typer.Typer(help="Run batch processing jobs.") + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context) -> None: + """Run batch processing jobs.""" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() + + +def _validate_non_negative(value: int | None, name: str) -> None: + if value is not None and value < 0: + raise typer.BadParameter(f"{name} must be non-negative") + + +def _run_async(coro): + configure_uvloop() + return asyncio.run(coro) + + +@app.command("structured") +def structured( + ctx: typer.Context, + input_path: Path = typer.Option(..., "--input", "-i", help="Input .jsonl or .csv file"), + output_path: Path = typer.Option(..., "--output", "-o", help="Output JSONL file"), + schema_path: Path | None = typer.Option(None, "--schema", help="JSON Schema file"), + schema_model: str | None = typer.Option( + None, + "--schema-model", + help="Pydantic BaseModel import path, for example myapp.schemas:Result", + ), + template_path: Path | None = typer.Option( + None, + "--template", + help="Row prompt template file; defaults to dumping the full row JSON", + ), + instruction_path: Path | None = typer.Option( + None, + "--instruction", + help="System instruction file; defaults to fast-agent's standard instruction", + ), + model: str | None = typer.Option(None, "--model", "-m", help="Model override"), + include_input: bool = typer.Option( + False, + "--include-input/--no-include-input", + help="Include the source row in each output envelope", + ), + limit: int | None = typer.Option(None, "--limit", help="Maximum selected rows to process"), + offset: int | None = typer.Option(None, "--offset", help="Rows to skip before sampling"), + sample: int | None = typer.Option(None, "--sample", help="Deterministic sample size"), + seed: int | None = typer.Option(None, "--seed", help="Deterministic sampling seed"), + resume: bool = typer.Option(False, "--resume", help="Append missing/retried rows"), + overwrite: bool = typer.Option(False, "--overwrite", help="Replace existing output"), + id_field: str | None = typer.Option(None, "--id-field", help="Input field used as row ID"), + max_errors: int | None = typer.Option( + None, + "--max-errors", + help="Stop after this many row-level failures", + ), + error_output_path: Path | None = typer.Option( + None, + "--error-output", + help="Additional JSONL file containing failed envelopes", + ), + telemetry_output_path: Path | None = typer.Option( + None, + "--telemetry-output", + help="JSONL file containing per-attempt normalized telemetry", + ), + summary_output_path: Path | None = typer.Option( + None, + "--summary-output", + help="Write final summary JSON to this path", + ), + final_summary: bool = typer.Option( + True, + "--final-summary/--no-final-summary", + help="Print final summary to stdout", + ), + shell_runtime: bool = CommonAgentOptions.shell(), +) -> None: + """Run one selected input row -> one structured model request -> one output record.""" + for value, name in ( + (limit, "--limit"), + (offset, "--offset"), + (sample, "--sample"), + (seed, "--seed"), + (max_errors, "--max-errors"), + ): + _validate_non_negative(value, name) + + if resume and overwrite: + raise typer.BadParameter("--resume and --overwrite cannot be used together") + if schema_path is not None and schema_model is not None: + raise typer.BadParameter("--schema and --schema-model cannot be used together") + if schema_path is None and schema_model is None: + raise typer.BadParameter("One of --schema or --schema-model is required") + + context = ensure_context_object(ctx) + env_dir = context.get("env_dir") + environment_dir = env_dir if isinstance(env_dir, Path) else None + + options = StructuredBatchOptions( + input_path=input_path, + output_path=output_path, + schema_path=schema_path, + schema_model=schema_model, + template_path=template_path, + instruction_path=instruction_path, + model=model, + include_input=include_input, + limit=limit, + offset=offset, + sample=sample, + seed=seed, + resume=resume, + overwrite=overwrite, + id_field=id_field, + max_errors=max_errors, + error_output_path=error_output_path, + telemetry_output_path=telemetry_output_path, + summary_output_path=summary_output_path, + final_summary=final_summary, + environment_dir=environment_dir, + shell_runtime=shell_runtime, + ) + + try: + summary = _run_async(run_structured_batch(options)) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + + if final_summary: + typer.echo(json.dumps(summary, ensure_ascii=False, indent=2)) diff --git a/src/fast_agent/cli/commands/check_config.py b/src/fast_agent/cli/commands/check_config.py index 54268e9e1..e00ee6b3d 100644 --- a/src/fast_agent/cli/commands/check_config.py +++ b/src/fast_agent/cli/commands/check_config.py @@ -1,13 +1,14 @@ """Command to check FastAgent configuration.""" +import asyncio import json import os import platform import sys -from dataclasses import dataclass +from dataclasses import asdict, dataclass from importlib.metadata import version from pathlib import Path -from typing import Any +from typing import Any, cast import typer import yaml @@ -15,12 +16,12 @@ from fast_agent.cli.env_helpers import resolve_environment_dir_option from fast_agent.cli.update_check import check_for_update_notice, should_run_update_check -from fast_agent.config import resolve_config_search_root from fast_agent.constants import DEFAULT_ENVIRONMENT_DIR from fast_agent.core.agent_card_validation import AgentCardScanResult, scan_agent_card_directory from fast_agent.core.exceptions import ModelConfigError from fast_agent.core.keyring_utils import KeyringStatus, get_keyring_status from fast_agent.core.logging.logger import get_logger +from fast_agent.home import discover_config_files, resolve_fast_agent_home from fast_agent.llm.model_factory import ModelFactory from fast_agent.llm.model_overlays import ModelOverlayRegistry, load_model_overlay_registry from fast_agent.llm.model_selection import ModelSelectionCatalog @@ -210,17 +211,12 @@ def _resolve_active_model_providers( def find_config_files(start_path: Path, env_dir: Path | None = None) -> dict[str, Path | None]: - """Find FastAgent configuration files using env, cwd, then legacy discovery.""" - from fast_agent.config import ( - resolve_implicit_config_file, - resolve_implicit_secrets_file, - ) - - config_path = resolve_implicit_config_file(start_path, env_dir=env_dir) - secrets_path = resolve_implicit_secrets_file(start_path, env_dir=env_dir) + """Find FastAgent configuration files using home then cwd discovery.""" + home = resolve_fast_agent_home(cwd=start_path, cli_override=env_dir) + discovery = discover_config_files(cwd=start_path, home=home) return { - "config": config_path, - "secrets": secrets_path, + "config": discovery.config_path, + "secrets": discovery.secrets_path, } @@ -740,7 +736,7 @@ def show_models_overview(env_dir: Path | None = None) -> None: alias_table.add_row(alias_token, model) console.print(alias_table) else: - console.print("[dim]No model_references configured in fastagent.config.yaml[/dim]") + console.print("[dim]No model_references configured in fast-agent.yaml[/dim]") console.print() console.print( @@ -1131,9 +1127,9 @@ def _validate_effective_settings( ) try: - merged_settings, _ = load_implicit_settings(start_path=cwd, env_dir=env_override) + merged_settings, discovery = load_implicit_settings(start_path=cwd, env_dir=env_override) - secrets_path = config_files.get("secrets") + secrets_path = discovery.secrets_path or config_files.get("secrets") if isinstance(secrets_path, Path): merged_settings = deep_merge(merged_settings, load_yaml_mapping(secrets_path)) @@ -1190,7 +1186,8 @@ def _load_optional_keyring_module() -> Any | None: def _build_check_summary_context(env_dir: Path | None) -> _CheckSummaryContext: cwd = Path.cwd() - search_root = resolve_config_search_root(cwd, env_dir=env_dir) + home = resolve_fast_agent_home(cwd=cwd, cli_override=env_dir) + search_root = home.path if home is not None else cwd config_files = find_config_files(cwd, env_dir=env_dir) system_info = get_system_info() config_summary = get_config_summary(config_files["config"]) @@ -2057,7 +2054,7 @@ def _render_check_summary_guidance(context: _CheckSummaryContext) -> None: console.print( "\n[yellow]No API keys configured. Set up API keys to use LLM services:[/yellow]" ) - console.print("1. Add keys to fastagent.secrets.yaml") + console.print("1. Add keys to fast-agent.secrets.yaml") env_vars = ", ".join( filter( None, @@ -2230,6 +2227,53 @@ def models( raise typer.BadParameter(str(exc), param_hint="provider") from exc +@app.command("structured-tools") +def structured_tools( + models: str = typer.Option( + ..., + "--models", + "--model", + help="Model id, alias, or comma-separated list of models to probe.", + ), + json_output: bool = typer.Option(False, "--json", help="Emit JSON output."), + structured_tool_policy: str = typer.Option( + "auto", + "--structured-tool-policy", + help="Policy to probe: auto, always, defer, or no_tools.", + ), +) -> None: + """Probe structured output compatibility when tools are available.""" + if structured_tool_policy not in {"auto", "always", "defer", "no_tools"}: + raise typer.BadParameter( + "structured tool policy must be 'auto', 'always', 'defer', or 'no_tools'", + param_hint="--structured-tool-policy", + ) + + model_names = [model.strip() for model in models.split(",") if model.strip()] + if not model_names: + raise typer.BadParameter("At least one model is required.", param_hint="--models") + + from fast_agent.cli.checks.structured_tools_probe import ( + StructuredToolPolicy, + _print_text_summary, + run_probe, + ) + + results = asyncio.run( + run_probe( + model_names, + structured_tool_policy=cast("StructuredToolPolicy", structured_tool_policy), + ) + ) + if json_output: + console.print_json(json.dumps([asdict(result) for result in results])) + else: + _print_text_summary(results) + + if not all(result.passed for result in results): + raise typer.Exit(1) + + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, diff --git a/src/fast_agent/cli/commands/config.py b/src/fast_agent/cli/commands/config.py index 4dcc6be89..a63b82463 100644 --- a/src/fast_agent/cli/commands/config.py +++ b/src/fast_agent/cli/commands/config.py @@ -12,8 +12,11 @@ LoggerSettings, ShellSettings, load_implicit_settings, - resolve_environment_config_file, - resolve_implicit_config_file, +) +from fast_agent.home import ( + PREFERRED_CONFIG_FILENAME, + discover_config_files, + resolve_fast_agent_home, ) from fast_agent.human_input.form_fields import FormSchema, boolean, integer, string from fast_agent.human_input.simple_form import form_sync @@ -30,7 +33,7 @@ typer.Option( "--config", "-c", - help="Path to config file (default: environment-dir fastagent.config.yaml)", + help="Path to config file (default: environment-dir fast-agent.yaml)", exists=False, # Allow non-existent files (will be created) ), ] @@ -38,10 +41,14 @@ def _default_config_file() -> Path: """Return the discovered implicit config path, or the default env path if none exist.""" - discovered_path = resolve_implicit_config_file(Path.cwd()) - if discovered_path is not None: - return discovered_path - return resolve_environment_config_file(Path.cwd()) + cwd = Path.cwd() + home = resolve_fast_agent_home(cwd=cwd) + discovery = discover_config_files(cwd=cwd, home=home) + if discovery.config_path is not None: + return discovery.config_path + if home is not None: + return home.path / PREFERRED_CONFIG_FILENAME + return cwd / PREFERRED_CONFIG_FILENAME def _load_config(config_path: Path | None = None) -> tuple[dict[str, Any], Path]: diff --git a/src/fast_agent/cli/commands/go.py b/src/fast_agent/cli/commands/go.py index 1f25de6b3..dea0ff6bb 100644 --- a/src/fast_agent/cli/commands/go.py +++ b/src/fast_agent/cli/commands/go.py @@ -128,6 +128,7 @@ def _build_compat_run_request(**kwargs: Any) -> AgentRunRequest: message=kwargs.get("message"), prompt_file=kwargs.get("prompt_file"), json_schema=kwargs.get("json_schema"), + schema_model=kwargs.get("schema_model"), result_file=kwargs.get("result_file"), resume=kwargs.get("resume"), url_servers=kwargs.get("url_servers"), @@ -139,6 +140,7 @@ def _build_compat_run_request(**kwargs: Any) -> AgentRunRequest: noenv=kwargs.get("noenv", False), force_smart=kwargs.get("force_smart", False), shell_runtime=kwargs.get("shell_runtime", False), + no_shell=kwargs.get("no_shell", False), mode=kwargs.get("mode", "interactive"), transport=kwargs.get("transport", "http"), host=kwargs.get("host", "0.0.0.0"), @@ -183,6 +185,7 @@ def run_async_agent( message: str | None = None, prompt_file: str | None = None, json_schema: str | None = None, + schema_model: str | None = None, result_file: str | None = None, resume: str | None = None, stdio_commands: list[str] | None = None, @@ -193,6 +196,7 @@ def run_async_agent( noenv: bool = False, force_smart: bool = False, shell_enabled: bool = False, + no_shell: bool = False, mode: Literal["interactive", "serve"] = "interactive", transport: str = "http", host: str = "0.0.0.0", @@ -222,6 +226,7 @@ def run_async_agent( message=message, prompt_file=prompt_file, json_schema=json_schema, + schema_model=schema_model, result_file=result_file, skills_directory=skills_directory, environment_dir=environment_dir, @@ -236,6 +241,7 @@ def run_async_agent( card_tools=card_tools, stdio_commands=stdio_commands, shell_enabled=shell_enabled, + no_shell=no_shell, transport=transport, instance_scope=resolve_instance_scope( transport=transport, @@ -350,6 +356,7 @@ def go( help="Prompt file to send once and exit (either text or JSON)", ), json_schema: str | None = CommonAgentOptions.json_schema(), + schema_model: str | None = CommonAgentOptions.schema_model(), results: str | None = typer.Option( None, "--results", @@ -368,6 +375,7 @@ def go( uvx: str | None = CommonAgentOptions.uvx(), stdio: str | None = CommonAgentOptions.stdio(), shell: bool = CommonAgentOptions.shell(), + no_shell: bool = CommonAgentOptions.no_shell(), reload: bool = typer.Option( False, "--reload", @@ -454,6 +462,7 @@ def go( message=message, prompt_file=prompt_file, json_schema=json_schema, + schema_model=schema_model, result_file=results, resume=resume, npx=npx, @@ -465,6 +474,7 @@ def go( noenv=noenv, force_smart=smart, shell_enabled=shell, + no_shell=no_shell, mode="interactive", instance_scope="shared", reload=reload, diff --git a/src/fast_agent/cli/commands/model.py b/src/fast_agent/cli/commands/model.py index b595721c2..4dd9e8688 100644 --- a/src/fast_agent/cli/commands/model.py +++ b/src/fast_agent/cli/commands/model.py @@ -25,7 +25,6 @@ deep_merge, load_implicit_settings, load_yaml_mapping, - resolve_implicit_secrets_file, ) from fast_agent.llm.llamacpp_discovery import ( DEFAULT_LLAMA_CPP_URL, @@ -41,6 +40,7 @@ ) from fast_agent.llm.model_overlays import ( LoadedModelOverlay, + build_model_overlay_manifest_from_database, load_model_overlay_registry, load_model_overlay_secret_entries, serialize_model_overlay_manifest, @@ -58,6 +58,7 @@ LlamaCppModelPickerContext, run_llamacpp_model_picker_async, ) +from fast_agent.ui.model_picker import run_model_picker_async from fast_agent.ui.model_reference_picker import ( ModelReferencePickerItem, run_model_reference_picker_async, @@ -655,8 +656,9 @@ def _load_cli_settings( cwd: Path, env_dir: str | Path | None, ) -> Settings: - merged_settings, config_file = load_implicit_settings(start_path=cwd, env_dir=env_dir) - secrets_path = resolve_implicit_secrets_file(cwd, env_dir=env_dir) + merged_settings, discovery = load_implicit_settings(start_path=cwd, env_dir=env_dir) + config_file = discovery.config_path + secrets_path = discovery.secrets_path if secrets_path and secrets_path.exists(): merged_settings = deep_merge(merged_settings, load_yaml_mapping(secrets_path)) @@ -672,8 +674,8 @@ def _load_tolerant_config_payload( env_dir: str | Path | None, ) -> dict[str, object] | None: try: - merged_settings, _ = load_implicit_settings(start_path=cwd, env_dir=env_dir) - secrets_path = resolve_implicit_secrets_file(cwd, env_dir=env_dir) + merged_settings, discovery = load_implicit_settings(start_path=cwd, env_dir=env_dir) + secrets_path = discovery.secrets_path if secrets_path and secrets_path.exists(): merged_settings = deep_merge(merged_settings, load_yaml_mapping(secrets_path)) except Exception: @@ -1428,6 +1430,134 @@ def model_main(ctx: typer.Context) -> None: raise typer.Exit(0) +@app.command("export", help="Export a model-database entry as a local overlay manifest.") +def model_export( + model: str | None = typer.Argument( + None, + help="Model name from the catalog (e.g. claude-4-sonnet-20250514). " + "Omit to choose interactively from the model picker.", + ), + name: str | None = typer.Option( + None, + "--name", + "-n", + help="Name for the generated overlay (defaults to a sanitized model name).", + ), + provider: str | None = typer.Option( + None, + "--provider", + "-p", + help="Override the provider for the overlay.", + ), + env: str | None = CommonAgentOptions.env_dir(), + replace: bool = typer.Option( + False, + "--replace", + "-r", + help="Overwrite an existing overlay with the same name.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + "-d", + help="Print the generated manifest without writing it.", + ), + json_output: bool = typer.Option( + False, + "--json", + "-j", + help="Emit a machine-readable JSON result.", + ), +) -> None: + """Export a ModelDatabase entry to a user-writable overlay manifest. + + The generated overlay pre-populates model_specific, modalities (tokenizes), + context window, and other parameters so users can customize them locally. + """ + import asyncio + + from fast_agent.llm.provider_types import Provider + + resolved_provider: Provider | None = None + if provider: + try: + resolved_provider = Provider(provider.lower()) + except ValueError: + typer.echo(f"Unknown provider: {provider}", err=True) + raise typer.Exit(1) + + async def _run_export() -> None: + selected_model = model + if selected_model is None: + # Use the normal TUI model picker so the user gets the full catalog + # with proper search, provider grouping, and no artificial limits. + picker_result = await run_model_picker_async() + if picker_result is None: + typer.echo("No model selected.", err=True) + raise typer.Exit(1) + + # resolved_model is the fully-qualified spec the user chose + # (e.g. "openrouter.gpt-4o" or bare "claude-4-sonnet-20250514") + selected_model = picker_result.resolved_model or picker_result.selected_model + if not selected_model: + typer.echo("No model selected.", err=True) + raise typer.Exit(1) + + if not selected_model: + typer.echo("Model name is required.", err=True) + raise typer.Exit(1) + + try: + manifest = build_model_overlay_manifest_from_database( + selected_model, + provider=resolved_provider, + overlay_name=name, + ) + except Exception as exc: + typer.echo(f"Failed to build overlay for '{selected_model}': {exc}", err=True) + raise typer.Exit(1) + + yaml_text = serialize_model_overlay_manifest(manifest) + + if dry_run: + if json_output: + payload = { + "overlay_name": manifest.name, + "dry_run": True, + "manifest": manifest.model_dump(mode="json", exclude_none=True), + } + typer.echo(json.dumps(payload, indent=2)) + else: + typer.echo(f"# Dry-run: would write overlay '{manifest.name}'") + typer.echo(yaml_text) + return + + try: + out_path = write_model_overlay_manifest( + manifest, + env_dir=env, + replace=replace, + ) + except FileExistsError as exc: + typer.echo(str(exc), err=True) + typer.echo("Use --replace to overwrite.", err=True) + raise typer.Exit(1) + + if json_output: + payload = { + "overlay_name": manifest.name, + "path": str(out_path), + "dry_run": False, + "manifest": manifest.model_dump(mode="json", exclude_none=True), + } + typer.echo(json.dumps(payload, indent=2)) + else: + typer.echo(f"Wrote overlay: {out_path}") + typer.echo(f"Use: fast-agent go --model {manifest.name}") + + asyncio.run(_run_export()) + + @app.command("setup") def model_setup( ctx: typer.Context, diff --git a/src/fast_agent/cli/commands/quickstart.py b/src/fast_agent/cli/commands/quickstart.py index c533fc3d6..b9930323c 100644 --- a/src/fast_agent/cli/commands/quickstart.py +++ b/src/fast_agent/cli/commands/quickstart.py @@ -50,7 +50,7 @@ class ExampleConfig: "parallel.py", "router.py", "short_story.txt", - "fastagent.config.yaml", + "fast-agent.yaml", ], create_subdir=True, path_in_examples=["workflows"], @@ -61,7 +61,7 @@ class ExampleConfig: "example. Uses Brave Search and Docker MCP Servers.\n" "Creates examples in a 'researcher' subdirectory." ), - files=["researcher.py", "researcher-eval.py", "fastagent.config.yaml"], + files=["researcher.py", "researcher-eval.py", "fast-agent.yaml"], create_subdir=True, path_in_examples=["researcher"], ), @@ -72,7 +72,7 @@ class ExampleConfig: "Creates examples in a 'data-analysis' subdirectory with mount-point for data.\n" "Uses MCP 'roots' feature for mapping" ), - files=["analysis.py", "fastagent.config.yaml"], + files=["analysis.py", "fast-agent.yaml"], mount_point_files=["WA_Fn-UseC_-HR-Employee-Attrition.csv"], create_subdir=True, path_in_examples=["data-analysis"], @@ -86,8 +86,8 @@ class ExampleConfig: files=[ "agent_one.py", "agent_two.py", - "fastagent.config.yaml", - "fastagent.secrets.yaml.example", + "fast-agent.yaml", + "fast-agent.secrets.yaml.example", ], create_subdir=True, path_in_examples=["mcp", "state-transfer"], @@ -102,8 +102,8 @@ class ExampleConfig: "elicitation_account_server.py", "elicitation_forms_server.py", "elicitation_game_server.py", - "fastagent.config.yaml", - "fastagent.secrets.yaml.example", + "fast-agent.yaml", + "fast-agent.secrets.yaml.example", "forms_demo.py", "game_character.py", "game_character_handler.py", @@ -124,7 +124,7 @@ class ExampleConfig: "README.md", "agent.py", "docker-compose.yml", - "fastagent.config.yaml", + "fast-agent.yaml", "image_demo.py", "simple_agent.py", "mcp_server/", @@ -132,7 +132,7 @@ class ExampleConfig: "tensorzero_config/", ], create_subdir=True, - path_in_examples=["elicitations"], + path_in_examples=["tensorzero"], ), "toad-cards": ExampleConfig( description=( @@ -459,7 +459,7 @@ def _show_completion_message(example_type: str, created: list[str]) -> None: console.print("2. The dataset is available in the mount-point directory:") console.print(" - mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv") console.print( - "On Windows platforms, please edit the fastagent.config.yaml and adjust the volume mount point." + "On Windows platforms, please edit the fast-agent.yaml and adjust the volume mount point." ) elif example_type == "state-transfer": console.print( diff --git a/src/fast_agent/cli/commands/serve.py b/src/fast_agent/cli/commands/serve.py index e133518a9..0110cf646 100644 --- a/src/fast_agent/cli/commands/serve.py +++ b/src/fast_agent/cli/commands/serve.py @@ -92,7 +92,9 @@ def _build_run_request( no_permissions: bool, reload: bool, watch: bool, + prefer_local_shell: bool = False, missing_shell_cwd: MissingShellCwdPolicy | None = None, + no_shell: bool = False, ) -> AgentRunRequest: resolved_env_dir = resolve_environment_dir_option(ctx, env_dir, set_env_var=not noenv) return build_command_run_request( @@ -119,6 +121,8 @@ def _build_run_request( noenv=noenv, force_smart=force_smart, shell_enabled=shell, + no_shell=no_shell, + prefer_local_shell=prefer_local_shell, mode="serve", transport=transport.value, host=host, @@ -186,6 +190,15 @@ def serve( help="Port to use when running as a server with HTTP transport", ), shell: bool = CommonAgentOptions.shell(), + no_shell: bool = CommonAgentOptions.no_shell(), + prefer_local_shell: bool = typer.Option( + False, + "--prefer-local-shell", + help=( + "When serving ACP with shell mode, use fast-agent's local shell runtime " + "instead of the ACP client's terminal capability" + ), + ), instance_scope: InstanceScope = typer.Option( InstanceScope.SHARED, "--instance-scope", @@ -230,6 +243,8 @@ def serve( host=host, port=port, shell=shell, + no_shell=no_shell, + prefer_local_shell=prefer_local_shell, instance_scope=_resolve_instance_scope( ctx, transport=transport, diff --git a/src/fast_agent/cli/commands/setup.py b/src/fast_agent/cli/commands/setup.py index a08abbe31..a586a8736 100644 --- a/src/fast_agent/cli/commands/setup.py +++ b/src/fast_agent/cli/commands/setup.py @@ -12,16 +12,16 @@ def load_template_text(filename: str) -> str: """Load template text from packaged resources only. - Special-case: when requesting 'fastagent.secrets.yaml', read the - 'fastagent.secrets.yaml.example' template from resources, but still + Special-case: when requesting 'fast-agent.secrets.yaml', read the + 'fast-agent.secrets.yaml.example' template from resources, but still return its contents so we can write out the real secrets file name in the destination project. """ from importlib.resources import files # Map requested filenames to resource templates - if filename == "fastagent.secrets.yaml": - res_name = "fastagent.secrets.yaml.example" + if filename == "fast-agent.secrets.yaml": + res_name = "fast-agent.secrets.yaml.example" elif filename == "pyproject.toml": res_name = "pyproject.toml.tmpl" else: @@ -93,8 +93,8 @@ def init( console.print("\n[bold]fast-agent scaffold[/bold]\n") console.print("This will create the following files:") - console.print(f" - {config_path}/fastagent.config.yaml") - console.print(f" - {config_path}/fastagent.secrets.yaml") + console.print(f" - {config_path}/fast-agent.yaml") + console.print(f" - {config_path}/fast-agent.secrets.yaml") console.print(f" - {config_path}/agent.py") console.print(f" - {config_path}/pyproject.toml") if needs_gitignore: @@ -106,14 +106,14 @@ def init( # Create configuration files created = [] if create_file( - config_path / "fastagent.config.yaml", load_template_text("fastagent.config.yaml"), force + config_path / "fast-agent.yaml", load_template_text("fast-agent.yaml"), force ): - created.append("fastagent.yaml") + created.append("fast-agent.yaml") if create_file( - config_path / "fastagent.secrets.yaml", load_template_text("fastagent.secrets.yaml"), force + config_path / "fast-agent.secrets.yaml", load_template_text("fast-agent.secrets.yaml"), force ): - created.append("fastagent.secrets.yaml") + created.append("fast-agent.secrets.yaml") if create_file(config_path / "agent.py", load_template_text("agent.py"), force): created.append("agent.py") @@ -152,21 +152,28 @@ def _render_pyproject(template_text: str) -> str: if created: console.print("\n[green]Scaffold completed successfully![/green]") + console.print(f"Created fast-agent home: {config_path}") + if "fast-agent.yaml" in created: + console.print(f"Created config file: {config_path / 'fast-agent.yaml'}") + if "fast-agent.secrets.yaml" in created: + console.print( + f"Created secrets file: {config_path / 'fast-agent.secrets.yaml'}" + ) if not needs_gitignore: console.print( "[yellow]Note:[/yellow] Found an existing .gitignore in this or a parent directory. " - "Ensure it ignores 'fastagent.secrets.yaml' to avoid committing secrets." + "Ensure it ignores 'fast-agent.secrets.yaml' to avoid committing secrets." ) - if "fastagent.secrets.yaml" in created: + if "fast-agent.secrets.yaml" in created: console.print("\n[yellow]Important:[/yellow] Remember to:") console.print( - "1. Add your API keys to fastagent.secrets.yaml, or set environment variables. Use [cyan]fast-agent check[/cyan] to verify." + "1. Add your API keys to fast-agent.secrets.yaml, or set environment variables. Use [cyan]fast-agent check[/cyan] to verify." ) console.print( - "2. Keep fastagent.secrets.yaml secure and never commit it to version control" + "2. Keep fast-agent.secrets.yaml secure and never commit it to version control" ) console.print( - "3. Update fastagent.config.yaml to set a default model (currently system default is 'gpt-5-mini?reasoning=low')" + "3. Update fast-agent.yaml to set a default model (currently system default is 'gpt-5-mini?reasoning=low')" ) console.print("\nTo get started, run:") console.print(" uv run agent.py") diff --git a/src/fast_agent/cli/constants.py b/src/fast_agent/cli/constants.py index ee1aa3ba4..ee704b9f2 100644 --- a/src/fast_agent/cli/constants.py +++ b/src/fast_agent/cli/constants.py @@ -34,6 +34,7 @@ def normalize_resume_flag_args(args: list[str], *, start_index: int = 0) -> None "--prompt-file", "-p", "--json-schema", + "--schema-model", "--results", "--servers", "--auth", @@ -42,6 +43,7 @@ def normalize_resume_flag_args(args: list[str], *, start_index: int = 0) -> None "--config-path", "-c", "--shell", + "--no-shell", "-x", "--skills", "--skills-dir", diff --git a/src/fast_agent/cli/env_helpers.py b/src/fast_agent/cli/env_helpers.py index 2edb99fdc..9417367e9 100644 --- a/src/fast_agent/cli/env_helpers.py +++ b/src/fast_agent/cli/env_helpers.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import TYPE_CHECKING +from fast_agent.constants import FAST_AGENT_RUNTIME_ENVIRONMENT + if TYPE_CHECKING: import typer @@ -37,6 +39,7 @@ def resolve_environment_dir_option( else: resolved = resolved.resolve() if set_env_var: + os.environ[FAST_AGENT_RUNTIME_ENVIRONMENT] = str(resolved) os.environ["ENVIRONMENT_DIR"] = str(resolved) return resolved diff --git a/src/fast_agent/cli/main.py b/src/fast_agent/cli/main.py index 960dcbea8..a414e5eef 100644 --- a/src/fast_agent/cli/main.py +++ b/src/fast_agent/cli/main.py @@ -29,6 +29,7 @@ "config": "fast_agent.cli.commands.config:app", "model": "fast_agent.cli.commands.model:app", "auth": "fast_agent.cli.commands.auth:app", + "batch": "fast_agent.cli.commands.batch:app", "quickstart": "fast_agent.cli.commands.quickstart:app", "bootstrap": "fast_agent.cli.commands.quickstart:app", "demo": "fast_agent.cli.commands.demo:app", @@ -100,6 +101,7 @@ def show_welcome(update_notice: str | None = None) -> None: table.add_row("skills", "Manage skills (list/available/search/add/remove/update)") table.add_row("config", "Configure settings interactively (shell, model)") table.add_row("auth", "Manage OAuth tokens in the OS keyring for MCP servers") + table.add_row("batch", "Run structured batch processing jobs") table.add_row("scaffold", "Create agent template and configuration") table.add_row("quickstart", "Create example applications (workflow, researcher, etc.)") table.add_row("demo", "Run local UI demos (no model calls)") diff --git a/src/fast_agent/cli/runtime/agent_setup.py b/src/fast_agent/cli/runtime/agent_setup.py index a1974511d..a6ecb4236 100644 --- a/src/fast_agent/cli/runtime/agent_setup.py +++ b/src/fast_agent/cli/runtime/agent_setup.py @@ -12,8 +12,8 @@ from typing import TYPE_CHECKING, Any, cast import typer -from jsonschema.exceptions import SchemaError from prompt_toolkit import PromptSession +from pydantic import BaseModel from fast_agent.cli.command_support import get_settings_or_exit from fast_agent.cli.commands.server_helpers import add_servers_to_config @@ -23,7 +23,10 @@ from fast_agent.core.logging.logger import get_logger from fast_agent.llm.model_reference_config import resolve_model_reference_start_path from fast_agent.llm.provider_types import Provider -from fast_agent.llm.structured_schema import validate_json_schema_definition +from fast_agent.llm.structured_schema import ( + StructuredSchemaSource, + load_structured_schema_source, +) from fast_agent.session.preview import find_last_assistant_preview_text from fast_agent.ui.interactive_diagnostics import write_interactive_trace from fast_agent.ui.model_picker_common import ( @@ -52,25 +55,20 @@ logger = get_logger(__name__) -def _load_structured_json_schema(path_str: str) -> dict[str, Any]: - schema_path = Path(path_str).expanduser() - try: - raw_text = schema_path.read_text(encoding="utf-8") - except OSError as exc: - raise ValueError(f"Could not read JSON schema file {schema_path}: {exc}") from exc +async def _structured_call( + agent_obj: Any, + prompt: Any, + schema_source: StructuredSchemaSource, +) -> tuple[Any | None, Any]: + if isinstance(schema_source, type) and issubclass(schema_source, BaseModel): + return await agent_obj.structured(prompt, schema_source) + return await agent_obj.structured_schema(prompt, schema_source) - try: - loaded = json.loads(raw_text) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid JSON schema file {schema_path}: {exc}") from exc - if not isinstance(loaded, dict): - raise ValueError(f"JSON schema file {schema_path} must contain a JSON object") - - try: - return validate_json_schema_definition(loaded) - except SchemaError as exc: - raise ValueError(f"Invalid JSON schema in {schema_path}: {exc.message}") from exc +def _structured_output_payload(parsed: Any) -> Any: + if isinstance(parsed, BaseModel): + return parsed.model_dump(mode="json") + return parsed def _find_last_assistant_text(history: list[Any]) -> str | None: @@ -119,7 +117,11 @@ def _load_request_settings(request: "AgentRunRequest"): if request.config_path is None: config_module._settings = None - return get_settings_or_exit(request.config_path) + return get_settings_or_exit( + request.config_path, + env_dir=request.environment_dir, + noenv=request.noenv, + ) def _resolve_model_picker_initial_selection( @@ -432,6 +434,7 @@ def _apply_shell_cwd_policy_preflight(fast: Any, request: AgentRunRequest) -> No issues = collect_shell_cwd_issues( fast.agents, shell_runtime_requested=request.shell_runtime, + no_shell=request.no_shell, cwd=Path.cwd(), ) if not issues: @@ -495,6 +498,7 @@ def _apply_shell_cwd_policy_preflight(fast: Any, request: AgentRunRequest) -> No remaining_issues = collect_shell_cwd_issues( fast.agents, shell_runtime_requested=request.shell_runtime, + no_shell=request.no_shell, cwd=Path.cwd(), ) if remaining_issues: @@ -625,7 +629,6 @@ async def _resume_session_if_requested(agent_app, request: AgentRunRequest) -> N assistant_text = _find_last_assistant_text(list(preview_history)) if assistant_text: if interactive_notice: - queue_startup_notice("[dim]Last assistant message:[/dim]") queue_startup_markdown_notice( assistant_text, title="Last assistant message", @@ -811,9 +814,12 @@ async def _run_single_agent_cli_flow(agent_app: Any, request: AgentRunRequest) - # Allow interactive prompt startup checks to honor per-run CLI override policy. agent_app.missing_shell_cwd_policy_override = request.missing_shell_cwd_policy try: - structured_schema = ( - _load_structured_json_schema(request.json_schema) - if request.json_schema is not None + structured_source = ( + load_structured_schema_source( + json_schema=request.json_schema, + schema_model=request.schema_model, + ) + if request.json_schema is not None or request.schema_model is not None else None ) except ValueError as exc: @@ -873,18 +879,18 @@ async def _run_interactive_with_interrupt_recovery() -> None: assert request.message is not None agent_obj = agent_app._agent(request.target_agent_name) history_before = [message.model_copy(deep=True) for message in agent_obj.message_history] - if structured_schema is None: + if structured_source is None: response = await agent_obj.generate(request.message) print(response.last_text() or "") else: - parsed, response = await agent_obj.structured_schema(request.message, structured_schema) + parsed, response = await _structured_call(agent_obj, request.message, structured_source) if parsed is None: typer.echo( - "Error: model response did not produce valid JSON matching --json-schema.", + "Error: model response did not produce valid JSON matching the structured output schema.", err=True, ) raise typer.Exit(1) - sys.stdout.write(json.dumps(parsed, ensure_ascii=False)) + sys.stdout.write(json.dumps(_structured_output_payload(parsed), ensure_ascii=False)) if request.result_file and not _response_was_persisted( history_before, agent_obj.message_history, @@ -898,18 +904,18 @@ async def _run_interactive_with_interrupt_recovery() -> None: prompt = load_prompt(Path(request.prompt_file)) agent_obj = agent_app._agent(request.target_agent_name) history_before = [message.model_copy(deep=True) for message in agent_obj.message_history] - if structured_schema is None: + if structured_source is None: response = await agent_obj.generate(prompt) print(response.last_text() or "") else: - parsed, response = await agent_obj.structured_schema(prompt, structured_schema) + parsed, response = await _structured_call(agent_obj, prompt, structured_source) if parsed is None: typer.echo( - "Error: model response did not produce valid JSON matching --json-schema.", + "Error: model response did not produce valid JSON matching the structured output schema.", err=True, ) raise typer.Exit(1) - sys.stdout.write(json.dumps(parsed, ensure_ascii=False)) + sys.stdout.write(json.dumps(_structured_output_payload(parsed), ensure_ascii=False)) if request.result_file and not _response_was_persisted( history_before, agent_obj.message_history, @@ -1007,6 +1013,7 @@ async def run_agent_request(request: AgentRunRequest) -> None: quiet=request.mode == "serve" or request.quiet, skills_directory=request.skills_directory, environment_dir=request.environment_dir, + noenv=request.noenv, ) if request.model: @@ -1018,7 +1025,7 @@ async def run_agent_request(request: AgentRunRequest) -> None: fast.args.watch = request.watch fast.args.agent = request.target_agent_name or request.agent_name or "agent" - if request.noenv or request.shell_runtime: + if request.noenv or request.shell_runtime or request.no_shell or request.prefer_local_shell: await fast.app.initialize() if request.noenv: config = fast.app.context.config @@ -1026,6 +1033,12 @@ async def run_agent_request(request: AgentRunRequest) -> None: config.session_history = False if request.shell_runtime: setattr(fast.app.context, "shell_runtime", True) + if request.no_shell: + setattr(fast.app.context, "no_shell", True) + if request.prefer_local_shell: + config = fast.app.context.config + if config is not None: + config.shell_execution.prefer_local_shell = True if request.url_servers: await add_servers_to_config( diff --git a/src/fast_agent/cli/runtime/request_builders.py b/src/fast_agent/cli/runtime/request_builders.py index 80087a1fd..253990478 100644 --- a/src/fast_agent/cli/runtime/request_builders.py +++ b/src/fast_agent/cli/runtime/request_builders.py @@ -126,6 +126,12 @@ def validate_noenv_conflicts( raise typer.BadParameter("Cannot combine --noenv with --resume.") +def validate_shell_conflicts(*, shell_enabled: bool, no_shell: bool) -> None: + """Validate unsupported shell option combinations.""" + if shell_enabled and no_shell: + raise typer.BadParameter("Cannot combine --shell with --no-shell.") + + def validate_execution_mode_inputs( *, message: str | None, @@ -146,20 +152,28 @@ def validate_execution_mode_inputs( def validate_json_schema_inputs( *, json_schema: str | None, + schema_model: str | None = None, execution_mode: ExecutionMode, model: str | None, ) -> None: - if json_schema is None: + if json_schema is not None and schema_model is not None: + raise typer.BadParameter( + "Cannot combine --json-schema with --schema-model.", + param_hint="--schema-model", + ) + if json_schema is None and schema_model is None: return if execution_mode == "repl": + option = "--schema-model" if schema_model is not None else "--json-schema" raise typer.BadParameter( - "--json-schema requires --message or --prompt-file", - param_hint="--json-schema", + f"{option} requires --message or --prompt-file", + param_hint=option, ) if is_multi_model(model): + option = "--schema-model" if schema_model is not None else "--json-schema" raise typer.BadParameter( - "Cannot combine --json-schema with multiple models.", - param_hint="--json-schema", + f"Cannot combine {option} with multiple models.", + param_hint=option, ) @@ -383,8 +397,11 @@ def build_agent_run_request( reload: bool, watch: bool, quiet: bool = False, + prefer_local_shell: bool = False, + no_shell: bool = False, missing_shell_cwd_policy: Literal["ask", "create", "warn", "error"] | None = None, json_schema: str | None = None, + schema_model: str | None = None, force_smart: bool = False, noenv: bool = False, ) -> AgentRunRequest: @@ -394,12 +411,14 @@ def build_agent_run_request( environment_dir=environment_dir, resume=resume, ) + validate_shell_conflicts(shell_enabled=shell_enabled, no_shell=no_shell) execution_mode = validate_execution_mode_inputs( message=message, prompt_file=prompt_file, ) validate_json_schema_inputs( json_schema=json_schema, + schema_model=schema_model, execution_mode=execution_mode, model=model, ) @@ -456,6 +475,7 @@ def build_agent_run_request( message=message, prompt_file=prompt_file, json_schema=json_schema, + schema_model=schema_model, result_file=result_file, resume=resume, url_servers=url_servers, @@ -467,6 +487,8 @@ def build_agent_run_request( noenv=noenv, force_smart=force_smart, shell_runtime=shell_enabled, + no_shell=no_shell, + prefer_local_shell=prefer_local_shell, mode=mode, transport=transport, host=host, @@ -530,10 +552,13 @@ def build_command_run_request( reload: bool = False, watch: bool = False, quiet: bool = False, + prefer_local_shell: bool = False, + no_shell: bool = False, missing_shell_cwd_policy: Literal["ask", "create", "warn", "error"] | None = None, force_smart: bool = False, noenv: bool = False, json_schema: str | None = None, + schema_model: str | None = None, ) -> AgentRunRequest: """Build a normalized request directly from command option values.""" validate_noenv_conflicts( @@ -541,6 +566,7 @@ def build_command_run_request( environment_dir=environment_dir, resume=resume, ) + validate_shell_conflicts(shell_enabled=shell_enabled, no_shell=no_shell) stdio_commands = collect_stdio_commands(npx, uvx, stdio) resolved_instruction, inferred_agent_name = resolve_instruction_option( @@ -564,6 +590,7 @@ def build_command_run_request( message=message, prompt_file=prompt_file, json_schema=json_schema, + schema_model=schema_model, result_file=result_file, resume=resume, stdio_commands=stdio_commands, @@ -574,6 +601,8 @@ def build_command_run_request( noenv=noenv, force_smart=force_smart, shell_enabled=shell_enabled, + prefer_local_shell=prefer_local_shell, + no_shell=no_shell, mode=mode, transport=transport, host=host, diff --git a/src/fast_agent/cli/runtime/run_request.py b/src/fast_agent/cli/runtime/run_request.py index 47abbb43e..c9e70e587 100644 --- a/src/fast_agent/cli/runtime/run_request.py +++ b/src/fast_agent/cli/runtime/run_request.py @@ -67,6 +67,7 @@ class AgentRunRequest: noenv: bool force_smart: bool shell_runtime: bool + no_shell: bool mode: Mode transport: str host: str @@ -78,13 +79,17 @@ class AgentRunRequest: reload: bool watch: bool json_schema: str | None = None + schema_model: str | None = None execution_mode: ExecutionMode | None = None quiet: bool = False missing_shell_cwd_policy: Literal["ask", "create", "warn", "error"] | None = None + prefer_local_shell: bool = False def __post_init__(self) -> None: if self.noenv and self.environment_dir is not None: raise ValueError("--noenv cannot be combined with --env") + if self.shell_runtime and self.no_shell: + raise ValueError("--shell cannot be combined with --no-shell") resolved_execution_mode = resolve_execution_mode( message=self.message, prompt_file=self.prompt_file, @@ -95,11 +100,13 @@ def __post_init__(self) -> None: raise ValueError( f"execution_mode {self.execution_mode!r} does not match request inputs" ) - if self.json_schema is not None: + if self.json_schema is not None and self.schema_model is not None: + raise ValueError("--json-schema cannot be combined with --schema-model") + if self.json_schema is not None or self.schema_model is not None: if self.execution_mode == "repl": - raise ValueError("--json-schema requires --message or --prompt-file") + raise ValueError("--json-schema/--schema-model requires --message or --prompt-file") if self.model is not None and "," in self.model: - raise ValueError("--json-schema cannot be combined with multiple models") + raise ValueError("structured output options cannot be combined with multiple models") self.quiet = True @property @@ -131,6 +138,7 @@ def to_agent_setup_kwargs(self) -> dict[str, Any]: "message": self.message, "prompt_file": self.prompt_file, "json_schema": self.json_schema, + "schema_model": self.schema_model, "result_file": self.result_file, "resume": self.resume, "url_servers": self.url_servers, @@ -142,6 +150,8 @@ def to_agent_setup_kwargs(self) -> dict[str, Any]: "noenv": self.noenv, "force_smart": self.force_smart, "shell_runtime": self.shell_runtime, + "no_shell": self.no_shell, + "prefer_local_shell": self.prefer_local_shell, "mode": self.mode, "transport": self.transport, "host": self.host, diff --git a/src/fast_agent/cli/runtime/shell_cwd_policy.py b/src/fast_agent/cli/runtime/shell_cwd_policy.py index f4245edf5..0a904044a 100644 --- a/src/fast_agent/cli/runtime/shell_cwd_policy.py +++ b/src/fast_agent/cli/runtime/shell_cwd_policy.py @@ -91,9 +91,13 @@ def collect_shell_cwd_issues( agents: Mapping[str, AgentCardData], *, shell_runtime_requested: bool, + no_shell: bool = False, cwd: Path | None = None, ) -> list[ShellCwdIssue]: """Collect invalid shell cwd entries from currently loaded agent configs.""" + if no_shell: + return [] + base_dir = cwd or Path.cwd() issues: list[ShellCwdIssue] = [] @@ -250,5 +254,8 @@ def _shell_runtime_active_for_agent( shell_runtime_requested: bool, shell_enabled: bool, skills_configured: bool, + no_shell: bool = False, ) -> bool: + if no_shell: + return False return shell_runtime_requested or shell_enabled or skills_configured diff --git a/src/fast_agent/cli/shared_options.py b/src/fast_agent/cli/shared_options.py index 3e908260e..bd88a7b51 100644 --- a/src/fast_agent/cli/shared_options.py +++ b/src/fast_agent/cli/shared_options.py @@ -85,7 +85,15 @@ def json_schema(): "--json-schema", help="Path to a JSON Schema file used for one-shot structured output", ) - + + @staticmethod + def schema_model(): + return typer.Option( + None, + "--schema-model", + help="Pydantic BaseModel import path used for one-shot structured output (module.path:ClassName)", + ) + @staticmethod def env_dir(): return typer.Option(None, "--env", help="Override the base fast-agent environment directory") @@ -119,6 +127,14 @@ def stdio(): def shell(): return typer.Option(False, "--shell", "-x", help="Enable a local shell runtime and expose the execute tool (bash or pwsh).") + @staticmethod + def no_shell(): + return typer.Option( + False, + "--no-shell", + help="Disable local shell/filesystem tools, even when skills or agent config request them.", + ) + @staticmethod def smart(): return typer.Option( diff --git a/src/fast_agent/cli/update_check.py b/src/fast_agent/cli/update_check.py index 1a5d15db3..a3ed52eca 100644 --- a/src/fast_agent/cli/update_check.py +++ b/src/fast_agent/cli/update_check.py @@ -5,7 +5,6 @@ import importlib.metadata import json import logging -import os import re import time from pathlib import Path @@ -48,12 +47,7 @@ def _resolve_environment_root( cwd: Path | None = None, ) -> Path: base = cwd or Path.cwd() - active_environment_dir: str | Path | None = environment_dir - if active_environment_dir is None: - env_override = os.getenv("ENVIRONMENT_DIR") - if env_override: - active_environment_dir = env_override - return resolve_environment_dir(cwd=base, override=active_environment_dir) + return resolve_environment_dir(cwd=base, override=environment_dir) def resolve_update_check_marker_path( diff --git a/src/fast_agent/command_actions/__init__.py b/src/fast_agent/command_actions/__init__.py new file mode 100644 index 000000000..0af24d905 --- /dev/null +++ b/src/fast_agent/command_actions/__init__.py @@ -0,0 +1,27 @@ +"""Plugin slash-command actions.""" + +from fast_agent.command_actions.config import parse_plugin_command_action_specs +from fast_agent.command_actions.models import ( + FAST_AGENT_AUDIT_CHANNEL, + PluginCommandAction, + PluginCommandActionContext, + PluginCommandActionFunction, + PluginCommandActionResult, + PluginCommandActionSpec, +) +from fast_agent.command_actions.registry import ( + PluginCommandActionRegistry, + normalize_plugin_command_action_result, +) + +__all__ = [ + "FAST_AGENT_AUDIT_CHANNEL", + "PluginCommandAction", + "PluginCommandActionContext", + "PluginCommandActionFunction", + "PluginCommandActionRegistry", + "PluginCommandActionResult", + "PluginCommandActionSpec", + "parse_plugin_command_action_specs", + "normalize_plugin_command_action_result", +] diff --git a/src/fast_agent/command_actions/accessors.py b/src/fast_agent/command_actions/accessors.py new file mode 100644 index 000000000..c18859821 --- /dev/null +++ b/src/fast_agent/command_actions/accessors.py @@ -0,0 +1,67 @@ +"""Optional capability accessors for plugin command actions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from pathlib import Path + + from fast_agent.command_actions.models import PluginCommandActionSpec + + +@runtime_checkable +class _PluginCommandConfig(Protocol): + commands: dict[str, PluginCommandActionSpec] | None + + +@runtime_checkable +class _PluginCommandAgent(Protocol): + config: _PluginCommandConfig + + +@runtime_checkable +class _PluginCommandProvider(Protocol): + plugin_commands: dict[str, PluginCommandActionSpec] | None + plugin_command_base_path: Path | None + + +@runtime_checkable +class _AgentLookupProvider(Protocol): + def get_agent(self, name: str) -> object | None: ... + + +@runtime_checkable +class _PrivateAgentLookupProvider(Protocol): + def _agent(self, name: str) -> object | None: ... + + +def plugin_commands_for_agent(agent: object | None) -> dict[str, PluginCommandActionSpec] | None: + if not isinstance(agent, _PluginCommandAgent): + return None + config = agent.config + if not isinstance(config, _PluginCommandConfig): + return None + return config.commands + + +def plugin_commands_for_provider( + provider: object | None, +) -> dict[str, PluginCommandActionSpec] | None: + if not isinstance(provider, _PluginCommandProvider): + return None + return provider.plugin_commands + + +def plugin_command_base_path_for_provider(provider: object | None) -> Path | None: + if not isinstance(provider, _PluginCommandProvider): + return None + return provider.plugin_command_base_path + + +def lookup_agent(provider: object | None, name: str) -> object | None: + if isinstance(provider, _AgentLookupProvider): + return provider.get_agent(name) + if isinstance(provider, _PrivateAgentLookupProvider): + return provider._agent(name) + return None diff --git a/src/fast_agent/command_actions/config.py b/src/fast_agent/command_actions/config.py new file mode 100644 index 000000000..1f3536a71 --- /dev/null +++ b/src/fast_agent/command_actions/config.py @@ -0,0 +1,61 @@ +"""Config parsing helpers for plugin command actions.""" + +from __future__ import annotations + +from typing import Any + +from fast_agent.command_actions.models import PluginCommandActionSpec +from fast_agent.core.exceptions import AgentConfigError + + +def parse_plugin_command_action_specs( + raw_commands: Any, + *, + source: str, +) -> dict[str, PluginCommandActionSpec] | None: + if raw_commands is None: + return None + if not isinstance(raw_commands, dict): + raise AgentConfigError(f"'commands' must be a dict in {source}") + + commands: dict[str, PluginCommandActionSpec] = {} + for raw_name, raw_value in raw_commands.items(): + name = str(raw_name).strip().lstrip("/") + if not name: + raise AgentConfigError(f"Command action names must not be empty in {source}") + if not isinstance(raw_value, dict): + raise AgentConfigError(f"Command action '{name}' must be a dict in {source}") + + description = raw_value.get("description") + if not isinstance(description, str) or not description.strip(): + raise AgentConfigError( + f"Command action '{name}' requires a non-empty 'description' in {source}" + ) + + handler = raw_value.get("handler") + if not isinstance(handler, str) or not handler.strip(): + raise AgentConfigError( + f"Command action '{name}' requires a non-empty 'handler' in {source}" + ) + + input_hint = raw_value.get("input_hint") + if input_hint is not None and not isinstance(input_hint, str): + raise AgentConfigError( + f"Command action '{name}' field 'input_hint' must be a string in {source}" + ) + + key = raw_value.get("key") + if key is not None and not isinstance(key, str): + raise AgentConfigError( + f"Command action '{name}' field 'key' must be a string in {source}" + ) + + commands[name] = PluginCommandActionSpec( + name=name, + description=description.strip(), + handler=handler.strip(), + input_hint=input_hint, + key=key, + ) + + return commands diff --git a/src/fast_agent/command_actions/loader.py b/src/fast_agent/command_actions/loader.py new file mode 100644 index 000000000..91582c910 --- /dev/null +++ b/src/fast_agent/command_actions/loader.py @@ -0,0 +1,69 @@ +"""Dynamic loader for plugin command action handlers.""" + +from __future__ import annotations + +import importlib.util +import inspect +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from fast_agent.core.exceptions import AgentConfigError + +if TYPE_CHECKING: + from fast_agent.command_actions.models import PluginCommandActionFunction + + +def load_plugin_command_action_function( + spec: str, + base_path: Path | None = None, +) -> PluginCommandActionFunction: + """Load an async command action function from ``path.py:function``.""" + if ":" not in spec: + raise AgentConfigError( + f"Invalid command action handler '{spec}'. Expected format: 'module.py:function_name'" + ) + + module_path_str, func_name = spec.rsplit(":", 1) + module_path = Path(module_path_str) + if not module_path.is_absolute(): + module_path = ((base_path or Path.cwd()) / module_path).resolve() + + if not module_path.exists(): + raise AgentConfigError( + f"Command action module file not found for '{spec}'", + f"Resolved path: {module_path}", + ) + + module_name = f"_plugin_command_action_{module_path.stem}_{id(spec)}" + import_spec = importlib.util.spec_from_file_location(module_name, module_path) + if import_spec is None or import_spec.loader is None: + raise AgentConfigError( + f"Failed to create module spec for command action '{spec}'", + f"Resolved path: {module_path}", + ) + + module = importlib.util.module_from_spec(import_spec) + try: + import_spec.loader.exec_module(module) + except Exception as exc: # noqa: BLE001 + raise AgentConfigError( + f"Failed to import command action module for '{spec}'", + str(exc), + ) from exc + + func = vars(module).get(func_name) + if func is None: + raise AgentConfigError( + f"Command action function '{func_name}' not found in '{module_path}'" + ) + if not callable(func): + raise AgentConfigError( + f"Command action target '{func_name}' in '{module_path}' is not callable" + ) + if not inspect.iscoroutinefunction(func): + raise AgentConfigError( + f"Command action function '{func_name}' must be async", + f"Resolved path: {module_path}", + ) + + return cast("PluginCommandActionFunction", func) diff --git a/src/fast_agent/command_actions/message_utils.py b/src/fast_agent/command_actions/message_utils.py new file mode 100644 index 000000000..89fc6477a --- /dev/null +++ b/src/fast_agent/command_actions/message_utils.py @@ -0,0 +1,22 @@ +"""Message helpers useful to plugin command actions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mcp.types import TextContent + +if TYPE_CHECKING: + from fast_agent.types import PromptMessageExtended + + +def replace_last_text(message: PromptMessageExtended, text: str) -> bool: + """Replace the last text content block on a message, adding one if absent.""" + for index in range(len(message.content) - 1, -1, -1): + block = message.content[index] + if isinstance(block, TextContent): + message.content[index] = TextContent(type="text", text=text) + return True + + message.content.append(TextContent(type="text", text=text)) + return False diff --git a/src/fast_agent/command_actions/models.py b/src/fast_agent/command_actions/models.py new file mode 100644 index 000000000..aebf4fc4c --- /dev/null +++ b/src/fast_agent/command_actions/models.py @@ -0,0 +1,141 @@ +"""Data types for plugin command actions.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Awaitable, Mapping, Protocol + +from mcp.types import TextContent + +if TYPE_CHECKING: + from pathlib import Path + + from fast_agent.agents.agent_types import AgentConfig + from fast_agent.config import Settings + from fast_agent.context import Context + from fast_agent.interfaces import AgentProtocol + from fast_agent.types import PromptMessageExtended + + +FAST_AGENT_AUDIT_CHANNEL = "fast-agent.audit" + + +class PluginCommandAgentProtocol(Protocol): + """Agent surface required by plugin command actions.""" + + @property + def name(self) -> str: ... + + @property + def message_history(self) -> list["PromptMessageExtended"]: ... + + def load_message_history(self, messages: list["PromptMessageExtended"] | None) -> None: ... + + @property + def context(self) -> "Context | None": ... + + @property + def config(self) -> "AgentConfig": ... + + @property + def agent_registry(self) -> "Mapping[str, AgentProtocol] | None": ... + + def get_agent(self, name: str) -> "AgentProtocol | None": ... + + async def send(self, message: str) -> str: ... + + +@dataclass(frozen=True, slots=True) +class PluginCommandActionSpec: + """Configured plugin slash-command metadata.""" + + name: str + description: str + handler: str + input_hint: str | None = None + key: str | None = None + + +@dataclass(slots=True) +class PluginCommandActionResult: + """Result returned by a plugin command action.""" + + message: str | None = None + markdown: str | None = None + buffer_prefill: str | None = None + switch_agent: str | None = None + refresh_agents: bool = False + + +class PluginCommandActionFunction(Protocol): + """Async plugin command action callable.""" + + __name__: str + + def __call__( + self, + ctx: "PluginCommandActionContext", + ) -> Awaitable[PluginCommandActionResult | str | None]: ... + + +@dataclass(frozen=True, slots=True) +class PluginCommandAction: + """Loaded plugin command action.""" + + spec: PluginCommandActionSpec + handler: PluginCommandActionFunction + + +@dataclass(slots=True) +class PluginCommandActionContext: + """Runtime context passed to plugin command actions.""" + + command_name: str + arguments: str + agent: PluginCommandAgentProtocol + settings: "Settings | None" = None + session_cwd: Path | None = None + + @property + def agent_name(self) -> str: + return self.agent.name + + @property + def context(self) -> "Context | None": + return self.agent.context + + @property + def message_history(self) -> list["PromptMessageExtended"]: + return self.agent.message_history + + @property + def agent_registry(self) -> "Mapping[str, AgentProtocol] | None": + return self.agent.agent_registry + + def load_message_history(self, messages: list["PromptMessageExtended"] | None) -> None: + self.agent.load_message_history(messages) + + def get_agent(self, name: str) -> "AgentProtocol | None": + return self.agent.get_agent(name) + + def mark_user_adjusted( + self, + message: "PromptMessageExtended", + *, + note: str | None = None, + ) -> None: + payload = { + "event": "user_adjusted", + "command": self.command_name, + "at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + if note: + payload["note"] = note + + channels = dict(message.channels or {}) + existing = list(channels.get(FAST_AGENT_AUDIT_CHANNEL, ())) + existing.append(TextContent(type="text", text=json.dumps(payload, separators=(",", ":")))) + channels[FAST_AGENT_AUDIT_CHANNEL] = existing + message.channels = channels diff --git a/src/fast_agent/command_actions/registry.py b/src/fast_agent/command_actions/registry.py new file mode 100644 index 000000000..753a9ed0b --- /dev/null +++ b/src/fast_agent/command_actions/registry.py @@ -0,0 +1,66 @@ +"""Registry and execution helpers for plugin command actions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from fast_agent.command_actions.loader import load_plugin_command_action_function +from fast_agent.command_actions.models import ( + PluginCommandAction, + PluginCommandActionContext, + PluginCommandActionResult, + PluginCommandActionSpec, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def normalize_plugin_command_action_result( + value: PluginCommandActionResult | str | None, +) -> PluginCommandActionResult: + if value is None: + return PluginCommandActionResult() + if isinstance(value, str): + return PluginCommandActionResult(message=value) + return value + + +@dataclass(slots=True) +class PluginCommandActionRegistry: + """Loaded command actions for one agent/app scope.""" + + _actions: dict[str, PluginCommandAction] = field(default_factory=dict) + + @classmethod + def from_specs( + cls, + specs: dict[str, PluginCommandActionSpec] | None, + *, + base_path: Path | None = None, + ) -> "PluginCommandActionRegistry": + registry = cls() + for name, spec in (specs or {}).items(): + handler = load_plugin_command_action_function(spec.handler, base_path=base_path) + registry.register(PluginCommandAction(spec=spec, handler=handler)) + return registry + + def register(self, action: PluginCommandAction) -> None: + self._actions[action.spec.name] = action + + def resolve(self, name: str) -> PluginCommandAction | None: + return self._actions.get(name) + + def list_actions(self) -> list[PluginCommandAction]: + return list(self._actions.values()) + + async def execute( + self, + name: str, + ctx: PluginCommandActionContext, + ) -> PluginCommandActionResult | None: + action = self.resolve(name) + if action is None: + return None + return normalize_plugin_command_action_result(await action.handler(ctx)) diff --git a/src/fast_agent/commands/handlers/models_manager.py b/src/fast_agent/commands/handlers/models_manager.py index 3474523ad..9d53bad2c 100644 --- a/src/fast_agent/commands/handlers/models_manager.py +++ b/src/fast_agent/commands/handlers/models_manager.py @@ -41,7 +41,7 @@ } _NO_MODEL_REFERENCES_NOTE = ( - "No model_references are configured. Add a model_references section in fastagent.config.yaml." + "No model_references are configured. Add a model_references section in fast-agent.yaml." ) _REFERENCES_USAGE = ( "Usage: /model references " diff --git a/src/fast_agent/commands/handlers/tools.py b/src/fast_agent/commands/handlers/tools.py index 8d640e2b0..2380fd348 100644 --- a/src/fast_agent/commands/handlers/tools.py +++ b/src/fast_agent/commands/handlers/tools.py @@ -18,11 +18,40 @@ from fast_agent.commands.context import CommandContext +APP_SUFFIX_BADGES = ("(Apps SDK)", "(MCP App)") + + +def _append_suffix(line: Text, suffix: str) -> None: + line.append(" ", style="dim cyan") + + index = 0 + while index < len(suffix): + badge = next( + (badge for badge in APP_SUFFIX_BADGES if suffix.startswith(badge, index)), + None, + ) + if badge: + line.append(badge, style="bright_yellow") + index += len(badge) + continue + + next_badge = min( + ( + position + for badge in APP_SUFFIX_BADGES + if (position := suffix.find(badge, index)) >= 0 + ), + default=len(suffix), + ) + line.append(suffix[index:next_badge], style="dim cyan") + index = next_badge + + def _format_tool_line(tool_name: str, title: str | None, suffix: str | None) -> Text: line = Text() line.append(tool_name, style="bright_blue bold") if suffix: - line.append(f" {suffix}", style="dim cyan") + _append_suffix(line, suffix) if title and title.strip(): line.append(f" {title}", style="default") return line diff --git a/src/fast_agent/commands/model_details.py b/src/fast_agent/commands/model_details.py index f7863871a..9235a9c3d 100644 --- a/src/fast_agent/commands/model_details.py +++ b/src/fast_agent/commands/model_details.py @@ -192,6 +192,9 @@ def _iter_model_identity_lines( if isinstance(context_window, int) and context_window > 0: lines.append(("Context window", str(context_window), False)) + if resolved_model is not None: + lines.extend(_iter_structured_output_lines(resolved_model)) + sampling_overrides = _render_sampling_overrides(llm) if sampling_overrides: lines.append(("Sampling overrides", sampling_overrides, False)) @@ -199,6 +202,35 @@ def _iter_model_identity_lines( return [(label, value, emphasize) for label, value, emphasize in lines if value] +def _iter_structured_output_lines( + resolved_model: "ResolvedModelSpec", +) -> list[tuple[str, str, bool]]: + json_mode = resolved_model.json_mode + if json_mode == "schema": + structured_output = "schema" + elif json_mode == "object": + structured_output = "object" + elif json_mode is None and resolved_model.model_params is not None: + structured_output = "prompt-only + local validation" + else: + structured_output = "unknown (defaulting to provider behavior)" + + policy = resolved_model.structured_tool_policy + if policy == "defer": + structured_tools = "two-phase (tools first, schema-only final)" + elif policy == "no_tools": + structured_tools = "structured-only (regular tools suppressed)" + elif policy == "always": + structured_tools = "same-request compatible" + else: + structured_tools = "default policy" + + return [ + ("Structured output", structured_output, False), + ("Structured + tools", structured_tools, False), + ] + + def _format_active_transport_value( configured_transport: str | None, active_transport: str, diff --git a/src/fast_agent/commands/tool_summaries.py b/src/fast_agent/commands/tool_summaries.py index 27e0ef197..685e111a4 100644 --- a/src/fast_agent/commands/tool_summaries.py +++ b/src/fast_agent/commands/tool_summaries.py @@ -55,9 +55,13 @@ def _tool_meta(tool: "Tool") -> dict[str, Any]: def _collect_tool_name_sets(agent: object) -> tuple[set[str], set[str], set[str]]: card_tool_names = set(agent.card_tool_names) if isinstance(agent, CardToolProvider) else set() - smart_tool_names = set(agent.smart_tool_names) if isinstance(agent, SmartToolingCapable) else set() + smart_tool_names = ( + set(agent.smart_tool_names) if isinstance(agent, SmartToolingCapable) else set() + ) agent_tool_names = ( - set(agent.agent_backed_tools.keys()) if isinstance(agent, AgentBackedToolProvider) else set() + set(agent.agent_backed_tools.keys()) + if isinstance(agent, AgentBackedToolProvider) + else set() ) return card_tool_names, smart_tool_names, agent_tool_names @@ -88,10 +92,12 @@ def build_tool_summaries(agent: object, tools: list[Tool]) -> list[ToolSummary]: suffix = "(MCP)" if meta.get("openai/skybridgeEnabled"): - suffix = f"{suffix} (skybridge)" if suffix else "(skybridge)" + suffix = f"{suffix} (Apps SDK)" if suffix else "(Apps SDK)" + if meta.get("ui/appEnabled"): + suffix = f"{suffix} (MCP App)" if suffix else "(MCP App)" args = _format_tool_args(tool.inputSchema) - template = meta.get("openai/skybridgeTemplate") + template = meta.get("ui/appTemplate") or meta.get("openai/skybridgeTemplate") summaries.append( ToolSummary( diff --git a/src/fast_agent/config.py b/src/fast_agent/config.py index 1719cd33e..8136e3359 100644 --- a/src/fast_agent/config.py +++ b/src/fast_agent/config.py @@ -19,8 +19,13 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from fast_agent.constants import DEFAULT_ENVIRONMENT_DIR +from fast_agent.command_actions import PluginCommandActionSpec, parse_plugin_command_action_specs from fast_agent.core.exceptions import ConfigFileError +from fast_agent.home import ( + ConfigDiscoveryResult, + discover_config_files, + resolve_fast_agent_home, +) from fast_agent.llm.reasoning_effort import ReasoningEffortSetting from fast_agent.llm.structured_output_mode import StructuredOutputMode from fast_agent.llm.task_budget import parse_task_budget_tokens, validate_task_budget_tokens @@ -169,7 +174,7 @@ class ShellSettings(BaseModel): timeout_seconds: int = Field( default=90, - description="Maximum seconds to wait for command output before terminating", + description="Maximum seconds without command output before terminating", ) warning_interval_seconds: int = Field( default=30, @@ -198,6 +203,13 @@ class ShellSettings(BaseModel): default="warn", description="Policy when an agent shell cwd is missing or invalid", ) + prefer_local_shell: bool = Field( + default=False, + description=( + "In ACP mode, keep the local fast-agent shell runtime instead of replacing it " + "with the ACP client's terminal runtime when the client advertises terminal support" + ), + ) enable_read_text_file: bool = Field( default=True, description=( @@ -975,6 +987,32 @@ class CodexResponsesSettings(ResponsesProviderSettingsBase): ) +class XAIResponsesSettings(BaseModel): + """Settings for using xAI's Responses-compatible API.""" + + api_key: str | None = Field(default=None, description="xAI API key") + base_url: str | None = Field( + default="https://api.x.ai/v1", + description="xAI API endpoint (default: https://api.x.ai/v1)", + ) + default_model: str | None = Field( + default=None, + description=( + "Default model when xAI Responses provider is selected without an explicit model" + ), + ) + default_headers: dict[str, str] | None = Field( + default=None, + description="Custom headers for all API requests", + ) + transport: Literal["sse", "websocket", "auto"] | None = Field( + default=None, + description="Responses transport mode override: sse, websocket, or auto fallback.", + ) + + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) + + class DeepSeekSettings(BaseModel): """Settings for using DeepSeek models in the fast-agent application.""" @@ -1005,6 +1043,10 @@ class GoogleSettings(BaseModel): default=None, description="Custom headers for all API requests", ) + transport: Literal["sse", "websocket", "auto"] | None = Field( + default=None, + description="Responses transport mode override: sse, websocket, or auto fallback.", + ) model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) @@ -1261,7 +1303,7 @@ class LoggerSettings(BaseModel): streaming: Literal["markdown", "plain", "none"] = "markdown" """Streaming renderer for assistant responses""" theme_file: str | None = None - """Optional Rich theme file for console styles. Relative paths resolve from fastagent.config.yaml.""" + """Optional Rich theme file for console styles. Relative paths resolve from fast-agent.yaml.""" code_theme: str = "native" """Pygments / Rich syntax theme for fenced code blocks and markdown code rendering.""" render_fences_with_syntax: bool = True @@ -1298,78 +1340,6 @@ def _coerce_apply_patch_preview_max_lines(cls, value: Any) -> int | None: return value -def find_fastagent_config_files(start_path: Path) -> tuple[Path | None, Path | None]: - """ - Find FastAgent configuration files with standardized behavior. - - Returns: - Tuple of (config_path, secrets_path) where either can be None if not found. - - Strategy: - 1. Find config file recursively from start_path upward - 2. Prefer secrets file in same directory as config file - 3. If no secrets file next to config, search recursively from start_path - """ - config_path = None - secrets_path = None - - # First, find the config file with recursive search - current = start_path.resolve() - while current != current.parent: - potential_config = current / "fastagent.config.yaml" - if potential_config.exists(): - config_path = potential_config - break - current = current.parent - - # If config file found, prefer secrets file in the same directory - if config_path: - potential_secrets = config_path.parent / "fastagent.secrets.yaml" - if potential_secrets.exists(): - secrets_path = potential_secrets - else: - # If no secrets file next to config, do recursive search from start - current = start_path.resolve() - while current != current.parent: - potential_secrets = current / "fastagent.secrets.yaml" - if potential_secrets.exists(): - secrets_path = potential_secrets - break - current = current.parent - else: - # No config file found, just search for secrets file - current = start_path.resolve() - while current != current.parent: - potential_secrets = current / "fastagent.secrets.yaml" - if potential_secrets.exists(): - secrets_path = potential_secrets - break - current = current.parent - - return config_path, secrets_path - - -def resolve_config_search_root( - start_path: Path, - *, - env_dir: str | Path | None = None, -) -> Path: - """Resolve the base path for discovering config and secrets files. - - If env_dir is provided (or ENVIRONMENT_DIR is set), search from there instead - of the current working directory. - """ - base = start_path.resolve() - override = env_dir if env_dir is not None else os.getenv("ENVIRONMENT_DIR") - if not override: - return base - - root = Path(override).expanduser() - if not root.is_absolute(): - root = (base / root).resolve() - return root - - def resolve_env_vars(config_item: Any) -> Any: """Recursively resolve environment variables in config data.""" if isinstance(config_item, dict): @@ -1427,137 +1397,29 @@ def load_yaml_mapping(path: Path | None) -> dict[str, Any]: return resolve_env_vars(payload) -def find_project_config_file(start_path: Path) -> Path | None: - """Find project-level ``fastagent.config.yaml`` from ``start_path`` upward.""" - current = start_path.resolve() - while current != current.parent: - candidate = current / "fastagent.config.yaml" - if candidate.exists(): - return candidate - current = current.parent - return None - - -def _find_parent_file(start_path: Path, filename: str) -> Path | None: - current = start_path.resolve().parent - while current != current.parent: - candidate = current / filename - if candidate.exists(): - return candidate - current = current.parent - return None - - -def find_legacy_project_config_file(start_path: Path) -> Path | None: - """Find a parent-directory ``fastagent.config.yaml``, excluding ``start_path`` itself.""" - return _find_parent_file(start_path, "fastagent.config.yaml") - - -def resolve_environment_config_file( - start_path: Path, - *, - env_dir: str | Path | None = None, -) -> Path: - """Return the env overlay config path: ``/fastagent.config.yaml``.""" - base = start_path.resolve() - override = env_dir if env_dir is not None else os.getenv("ENVIRONMENT_DIR") - - if override: - env_root = Path(override).expanduser() - if not env_root.is_absolute(): - env_root = (base / env_root).resolve() - else: - env_root = (base / DEFAULT_ENVIRONMENT_DIR).resolve() - - return env_root / "fastagent.config.yaml" - - -def resolve_implicit_config_file( - start_path: Path, - *, - env_dir: str | Path | None = None, -) -> Path | None: - """Resolve the active implicit config file using env, cwd, then legacy order.""" - env_config = resolve_environment_config_file(start_path, env_dir=env_dir) - if env_config.exists(): - return env_config - - cwd_config = start_path.resolve() / "fastagent.config.yaml" - if cwd_config.exists(): - return cwd_config - - return find_legacy_project_config_file(start_path) - - -def resolve_implicit_secrets_file( - start_path: Path, - *, - env_dir: str | Path | None = None, -) -> Path | None: - """Resolve the active implicit secrets file using env, cwd, then legacy order.""" - env_secrets = resolve_environment_config_file(start_path, env_dir=env_dir).with_name( - "fastagent.secrets.yaml" - ) - if env_secrets.exists(): - return env_secrets - - cwd_secrets = start_path.resolve() / "fastagent.secrets.yaml" - if cwd_secrets.exists(): - return cwd_secrets - - return _find_parent_file(start_path, "fastagent.secrets.yaml") - - def load_implicit_settings( *, start_path: Path, env_dir: str | Path | None = None, -) -> tuple[dict[str, Any], Path | None]: - """Load settings from the first implicit config found: env, cwd, then legacy.""" - config_path = resolve_implicit_config_file(start_path, env_dir=env_dir) - if config_path is None or not config_path.exists(): - return {}, None - return load_yaml_mapping(config_path), config_path - - -def resolve_layered_config_file( - start_path: Path, - *, - env_dir: str | Path | None = None, -) -> Path | None: - """Return the implicit config path using env, cwd, then legacy precedence.""" - return resolve_implicit_config_file(start_path, env_dir=env_dir) + noenv: bool = False, +) -> tuple[dict[str, Any], ConfigDiscoveryResult]: + """Load settings from the discovered config file.""" + home = resolve_fast_agent_home(cwd=start_path, cli_override=env_dir, noenv=noenv) + discovery = discover_config_files(cwd=start_path, home=home) + merged: dict[str, Any] = {} + if discovery.config_path and discovery.config_path.exists(): + merged = load_yaml_mapping(discovery.config_path) + return merged, discovery -def load_layered_settings( +def load_selected_settings( *, start_path: Path, env_dir: str | Path | None = None, -) -> tuple[dict[str, Any], Path | None]: - """Load merged settings from project config with env overlay. - - Precedence: project config < environment config. - - Returns: - A tuple of ``(merged_settings, effective_config_path)`` where - ``effective_config_path`` is the last-applied config path (env when - present, else project), or ``None`` when neither exists. - """ - - merged: dict[str, Any] = {} - effective_config: Path | None = None - - project_config = find_project_config_file(start_path) - if project_config and project_config.exists(): - merged = deep_merge(merged, load_yaml_mapping(project_config)) - effective_config = project_config - - env_config = resolve_environment_config_file(start_path, env_dir=env_dir) - if env_config.exists(): - merged = deep_merge(merged, load_yaml_mapping(env_config)) - effective_config = env_config - - return merged, effective_config + noenv: bool = False, +) -> tuple[dict[str, Any], ConfigDiscoveryResult]: + """Load first-found config/secrets settings with home then cwd precedence.""" + return load_implicit_settings(start_path=start_path, env_dir=env_dir, noenv=noenv) def load_layered_model_settings( @@ -1571,7 +1433,7 @@ def load_layered_model_settings( ``model_references`` uses deep-merge semantics, while ``default_model`` uses scalar replacement semantics. """ - layered_settings, _ = load_layered_settings(start_path=start_path, env_dir=env_dir) + layered_settings, _ = load_selected_settings(start_path=start_path, env_dir=env_dir) layered: dict[str, Any] = {} if "default_model" in layered_settings: @@ -1655,6 +1517,9 @@ class Settings(BaseSettings): codexresponses: CodexResponsesSettings | None = None """Settings for using Codex Responses models in the fast-agent application""" + xairesponses: XAIResponsesSettings | None = None + """Settings for using xAI Responses models in the fast-agent application""" + deepseek: DeepSeekSettings | None = None """Settings for using DeepSeek models in the fast-agent application""" @@ -1712,6 +1577,9 @@ class Settings(BaseSettings): cards: CardsSettings = CardsSettings() """Card pack registry selection settings.""" + commands: dict[str, PluginCommandActionSpec] | None = None + """Global plugin command actions loaded from fast-agent.yaml.""" + shell_execution: ShellSettings = ShellSettings() """Shell execution timeout and warning settings.""" @@ -1723,6 +1591,14 @@ class Settings(BaseSettings): _config_file: str | None = PrivateAttr(default=None) _secrets_file: str | None = PrivateAttr(default=None) + _fast_agent_home: str | None = PrivateAttr(default=None) + _fast_agent_home_source: str | None = PrivateAttr(default=None) + _fast_agent_noenv: bool = PrivateAttr(default=False) + + @field_validator("commands", mode="before") + @classmethod + def _validate_plugin_commands(cls, value: Any) -> dict[str, PluginCommandActionSpec] | None: + return parse_plugin_command_action_specs(value, source="fast-agent.yaml") @field_validator("model_references") @classmethod @@ -1758,37 +1634,64 @@ def _validate_model_references( @classmethod def find_config(cls) -> Path | None: - """Find the config file in the current directory or parent directories.""" - current_dir = Path.cwd() - - # Check current directory and parent directories - while current_dir != current_dir.parent: - for filename in [ - "fastagent.config.yaml", - ]: - config_path = current_dir / filename - if config_path.exists(): - return config_path - current_dir = current_dir.parent - - return None + """Find the preferred config file in the current directory.""" + config_path = Path.cwd() / "fast-agent.yaml" + return config_path if config_path.exists() else None # Global settings object _settings: Settings | None = None -def get_settings(config_path: str | os.PathLike[str] | None = None) -> Settings: +def _cached_settings_match_environment_request( + settings: Settings, + *, + env_dir: str | Path | None, + noenv: bool, +) -> bool: + if noenv: + return settings._fast_agent_noenv + + if settings._fast_agent_noenv: + return False + + if env_dir is None and settings._fast_agent_home is None: + return True + + requested_home = resolve_fast_agent_home( + cwd=Path.cwd(), + cli_override=env_dir, + noenv=False, + ) + return ( + requested_home is not None + and settings._fast_agent_home == str(requested_home.path) + and (env_dir is None or settings._fast_agent_home_source == "cli") + ) + + +def get_settings( + config_path: str | os.PathLike[str] | None = None, + *, + env_dir: str | os.PathLike[str] | None = None, + noenv: bool = False, +) -> Settings: """Get settings instance, automatically loading from config file if available.""" global _settings + env_dir_override = Path(env_dir) if env_dir is not None and not isinstance(env_dir, str) else env_dir + # If we have a specific config path, always reload settings # This ensures each test gets its own config if config_path: # Reset for the new path _settings = None - elif _settings: + elif _settings and _cached_settings_match_environment_request( + _settings, + env_dir=env_dir_override, + noenv=noenv, + ): # Use cached settings only for no specific path return _settings @@ -1806,10 +1709,16 @@ def get_settings(config_path: str | os.PathLike[str] | None = None) -> Settings: if resolved_path.exists(): config_file = resolved_path - # When config path is explicitly provided, find secrets using standardized logic - secrets_file = None - if config_file.exists(): - _, secrets_file = find_fastagent_config_files(config_file.parent) + discovery = discover_config_files( + cwd=Path.cwd(), + home=resolve_fast_agent_home( + cwd=Path.cwd(), + cli_override=env_dir_override, + noenv=noenv, + ), + explicit_config_path=config_file, + ) + secrets_file = discovery.secrets_path if config_file.exists() else None merged_settings = {} # Load main config if it exists @@ -1819,18 +1728,15 @@ def get_settings(config_path: str | os.PathLike[str] | None = None) -> Settings: elif config_file and not config_file.exists(): print(f"Warning: Specified config file does not exist: {config_file}") else: - # Implicit discovery prefers env-specific config, then cwd, then parent fallback. - env_override = os.getenv("ENVIRONMENT_DIR") - merged_settings, config_file = load_implicit_settings( + merged_settings, discovery = load_implicit_settings( start_path=Path.cwd(), - env_dir=env_override, + env_dir=env_dir_override, + noenv=noenv, ) + config_file = discovery.config_path + secrets_file = discovery.secrets_path if config_file and config_file.exists(): - config_sources.append((config_file, merged_settings)) - secrets_file = resolve_implicit_secrets_file( - Path.cwd(), - env_dir=env_override, - ) + config_sources.append((config_file, load_yaml_mapping(config_file))) # Load secrets file if found (regardless of whether config file exists) if secrets_file and secrets_file.exists(): @@ -1858,6 +1764,9 @@ def get_settings(config_path: str | os.PathLike[str] | None = None) -> Settings: _settings = Settings(**merged_settings) _settings._config_file = str(config_file) if config_file else None _settings._secrets_file = str(secrets_file) if secrets_file else None + _settings._fast_agent_home = str(discovery.home.path) if discovery.home else None + _settings._fast_agent_home_source = discovery.home.source if discovery.home else None + _settings._fast_agent_noenv = noenv current_theme_file = getattr(_settings.logger, "theme_file", None) if current_theme_file is not None: for source_path, source_mapping in reversed(config_sources): diff --git a/src/fast_agent/constants.py b/src/fast_agent/constants.py index 8b433b3b4..a9b0e0f7a 100644 --- a/src/fast_agent/constants.py +++ b/src/fast_agent/constants.py @@ -75,6 +75,8 @@ def should_parallelize_tool_calls(tool_call_count: int) -> bool: Mermaid diagrams between code fences are supported. +{{model_specific}} + The current date is {{currentDate}}.""" @@ -100,4 +102,7 @@ def should_parallelize_tool_calls(tool_call_count: int) -> bool: FAST_AGENT_SHELL_CHILD_ENV = "FAST_AGENT_SHELL_CHILD" """Environment variable set when running fast-agent shell commands.""" +FAST_AGENT_RUNTIME_ENVIRONMENT = "FAST_AGENT_RUNTIME_ENVIRONMENT" +"""Resolved active fast-agent home exported to shell commands and automation.""" + SHELL_NOTICE_PREFIX = "[yellow][bold]Agents have shell[/bold][/yellow]" diff --git a/src/fast_agent/context.py b/src/fast_agent/context.py index a4a360919..94c0df156 100644 --- a/src/fast_agent/context.py +++ b/src/fast_agent/context.py @@ -58,6 +58,7 @@ class Context(BaseModel): server_registry: ServerRegistry | None = None task_registry: ActivityRegistry | None = None skill_registry: SkillRegistry | None = None + no_shell: bool = False tracer: trace.Tracer | None = None _connection_manager: "MCPConnectionManager | None" = None diff --git a/src/fast_agent/core/agent_app.py b/src/fast_agent/core/agent_app.py index e9cfa9e2c..3f4a6c4e1 100644 --- a/src/fast_agent/core/agent_app.py +++ b/src/fast_agent/core/agent_app.py @@ -5,6 +5,7 @@ import time from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING, Awaitable, Callable, Mapping, Sequence, Union from deprecated import deprecated @@ -14,6 +15,7 @@ from fast_agent.agents.agent_types import AgentType from fast_agent.agents.workflow.parallel_agent import ParallelAgent +from fast_agent.command_actions import PluginCommandActionSpec from fast_agent.core.default_agent import agent_is_default, resolve_default_agent_name from fast_agent.core.exceptions import AgentConfigError, ServerConfigError from fast_agent.core.logging.logger import get_logger @@ -77,6 +79,8 @@ def __init__( tool_only_agents: set[str] | None = None, card_collision_warnings: list[str] | None = None, noenv_mode: bool = False, + plugin_commands: dict[str, PluginCommandActionSpec] | None = None, + plugin_command_base_path: Path | None = None, ) -> None: """ Initialize the DirectAgentApp. @@ -109,6 +113,8 @@ def __init__( ) self._tool_only_agents: set[str] = tool_only_agents or set() self._card_collision_warnings: list[str] = card_collision_warnings or [] + self._plugin_commands = plugin_commands + self._plugin_command_base_path = plugin_command_base_path self._last_refresh_result = AgentRefreshResult(changed=False) self._noenv_mode = noenv_mode self._missing_shell_cwd_policy_override: "MissingShellCwdPolicy | None" = None @@ -130,6 +136,23 @@ def get_agent(self, name: str) -> AgentProtocol | None: """Return the named agent if available, else None.""" return self._agents.get(name) + @property + def plugin_commands(self) -> dict[str, PluginCommandActionSpec] | None: + return self._plugin_commands + + @property + def plugin_command_base_path(self) -> Path | None: + return self._plugin_command_base_path + + def set_plugin_commands( + self, + commands: dict[str, PluginCommandActionSpec] | None, + *, + base_path: Path | None, + ) -> None: + self._plugin_commands = commands + self._plugin_command_base_path = base_path + def resolve_target_agent_name(self, agent_name: str | None = None) -> str | None: if agent_name is None: return self.get_default_agent_name() diff --git a/src/fast_agent/core/agent_card_loader.py b/src/fast_agent/core/agent_card_loader.py index 8ff193ebc..ddd4e07d0 100644 --- a/src/fast_agent/core/agent_card_loader.py +++ b/src/fast_agent/core/agent_card_loader.py @@ -17,6 +17,7 @@ FunctionToolConfig, MCPConnectTarget, ) +from fast_agent.command_actions import PluginCommandActionSpec, parse_plugin_command_action_specs from fast_agent.config import MCPServerSettings, resolve_env_vars from fast_agent.constants import DEFAULT_AGENT_INSTRUCTION, SMART_AGENT_INSTRUCTION from fast_agent.core.agent_card_rules import ( @@ -411,6 +412,8 @@ def _build_agent_data( f"Valid types are: {sorted(VALID_LIFECYCLE_HOOK_TYPES)}", ) + commands = _ensure_plugin_commands(raw.get("commands"), path) + # Parse trim_tool_history shortcut trim_tool_history = _ensure_bool(raw.get("trim_tool_history"), "trim_tool_history", path) @@ -435,6 +438,7 @@ def _build_agent_data( cwd=cwd, tool_hooks=tool_hooks, lifecycle_hooks=lifecycle_hooks, + commands=commands, trim_tool_history=trim_tool_history, mcp_connect=mcp_connect, source_path=path, @@ -523,6 +527,10 @@ def _build_agent_data( return agent_data +def _ensure_plugin_commands(raw_commands: Any, path: Path) -> dict[str, PluginCommandActionSpec] | None: + return parse_plugin_command_action_specs(raw_commands, source=str(path)) + + def _default_use_history(type_key: str, raw_value: Any) -> bool: if isinstance(raw_value, bool): return raw_value @@ -1002,6 +1010,21 @@ def _serialize_agent_like_fields( if config.lifecycle_hooks: card["lifecycle_hooks"] = config.lifecycle_hooks + if config.commands: + card["commands"] = { + name: { + key: value + for key, value in { + "description": spec.description, + "input_hint": spec.input_hint, + "handler": spec.handler, + "key": spec.key, + }.items() + if value is not None + } + for name, spec in config.commands.items() + } + if config.trim_tool_history: card["trim_tool_history"] = True diff --git a/src/fast_agent/core/agent_card_rules.py b/src/fast_agent/core/agent_card_rules.py index f9e8db4ac..318c24ed5 100644 --- a/src/fast_agent/core/agent_card_rules.py +++ b/src/fast_agent/core/agent_card_rules.py @@ -66,6 +66,7 @@ "function_tools", "tool_hooks", "lifecycle_hooks", + "commands", "trim_tool_history", "tool_input_schema", "messages", diff --git a/src/fast_agent/core/core_app.py b/src/fast_agent/core/core_app.py index ca4fba51f..17b939f2b 100644 --- a/src/fast_agent/core/core_app.py +++ b/src/fast_agent/core/core_app.py @@ -33,7 +33,7 @@ def __init__( Initialize the core. Args: name: - settings: If unspecified, the settings are loaded from fastagent.config.yaml. + settings: If unspecified, the settings are loaded from fast-agent.yaml. If this is a string or path-like object, it is treated as the path to the config file to load. signal_notification: Callback for getting notified on workflow signals/events. """ diff --git a/src/fast_agent/core/fastagent.py b/src/fast_agent/core/fastagent.py index 7cffe1b47..082e49348 100644 --- a/src/fast_agent/core/fastagent.py +++ b/src/fast_agent/core/fastagent.py @@ -173,6 +173,7 @@ def __init__( quiet: bool = False, # Add quiet parameter environment_dir: str | pathlib.Path | None = None, skills_directory: str | pathlib.Path | Sequence[str | pathlib.Path] | None = None, + noenv: bool = False, **kwargs, ) -> None: """ @@ -303,6 +304,12 @@ def __init__( action="store_true", help="Watch AgentCard paths and reload when files change", ) + parser.add_argument( + "--noenv", + "--no-env", + action="store_true", + help="Disable fast-agent home/environment directory use", + ) parser.add_argument( "--card-tool", action="append", @@ -364,11 +371,19 @@ def __init__( if self._programmatic_quiet: self.args.quiet = True + if noenv: + self.args.noenv = True + elif not hasattr(self.args, "noenv"): + self.args.noenv = False + # Apply CLI environment directory if not already set programmatically if self._environment_dir_override is None and hasattr(self.args, "env") and self.args.env: self._environment_dir_override = self._normalize_environment_dir(self.args.env) if self._environment_dir_override is not None: + from fast_agent.constants import FAST_AGENT_RUNTIME_ENVIRONMENT + + os.environ[FAST_AGENT_RUNTIME_ENVIRONMENT] = str(self._environment_dir_override) os.environ["ENVIRONMENT_DIR"] = str(self._environment_dir_override) # Apply CLI skills directory if not already set programmatically @@ -411,6 +426,13 @@ def __init__( if instance_settings is not None: instance_settings._config_file = getattr(self, "_loaded_config_file", None) instance_settings._secrets_file = getattr(self, "_loaded_secrets_file", None) + instance_settings._fast_agent_home = getattr(self, "_loaded_fast_agent_home", None) + instance_settings._fast_agent_home_source = getattr( + self, "_loaded_fast_agent_home_source", None + ) + instance_settings._fast_agent_noenv = bool( + getattr(self, "_loaded_fast_agent_noenv", False) + ) if instance_settings is not None: config.update_global_settings(instance_settings) @@ -501,9 +523,18 @@ def _load_config(self) -> None: try: # Use get_settings to load config - this handles all paths and secrets merging - settings = _config_module.get_settings(self.config_path) + settings = _config_module.get_settings( + self.config_path, + env_dir=self._environment_dir_override, + noenv=bool(getattr(self.args, "noenv", False)), + ) self._loaded_config_file = settings._config_file if settings else None self._loaded_secrets_file = settings._secrets_file if settings else None + self._loaded_fast_agent_home = settings._fast_agent_home if settings else None + self._loaded_fast_agent_home_source = ( + settings._fast_agent_home_source if settings else None + ) + self._loaded_fast_agent_noenv = settings._fast_agent_noenv if settings else False # Convert to dict for backward compatibility self.config = settings.model_dump() if settings else {} finally: @@ -1560,12 +1591,18 @@ async def _instantiate_agent_instance( tool_only_agents = { name for name, data in self.agents.items() if data.get("tool_only", False) } + settings = config.get_settings() + plugin_command_base_path = ( + Path(settings._config_file).parent if settings._config_file is not None else None + ) if app_override is None: app = AgentApp( agents_map, tool_only_agents=tool_only_agents, card_collision_warnings=self._card_collision_warnings, noenv_mode=runtime.noenv_mode, + plugin_commands=settings.commands, + plugin_command_base_path=plugin_command_base_path, ) else: app_override.set_agents( @@ -1573,6 +1610,10 @@ async def _instantiate_agent_instance( tool_only_agents=tool_only_agents, card_collision_warnings=self._card_collision_warnings, ) + app_override.set_plugin_commands( + settings.commands, + base_path=plugin_command_base_path, + ) app_override.noenv_mode = runtime.noenv_mode app = app_override @@ -2739,13 +2780,13 @@ def _handle_error(self, e: Exception, error_type: str | None = None) -> None: handle_error( e, "Server Configuration Error", - "Please check your 'fastagent.config.yaml' configuration file and add the missing server definitions.", + "Please check your 'fast-agent.yaml' configuration file and add the missing server definitions.", ) elif isinstance(e, ProviderKeyError): handle_error( e, "Provider Configuration Error", - "Please check your 'fastagent.secrets.yaml' configuration file and ensure all required API keys are set.", + "Please check your 'fast-agent.secrets.yaml' configuration file and ensure all required API keys are set.", ) elif isinstance(e, AgentConfigError): handle_error( diff --git a/src/fast_agent/core/instruction_utils.py b/src/fast_agent/core/instruction_utils.py index f377ef907..ad26bd4af 100644 --- a/src/fast_agent/core/instruction_utils.py +++ b/src/fast_agent/core/instruction_utils.py @@ -11,12 +11,14 @@ build_instruction, resolve_instruction_skill_manifests, ) +from fast_agent.llm.model_database import ModelDatabase if TYPE_CHECKING: from collections.abc import Iterable, Mapping from fast_agent.agents.agent_types import AgentConfig from fast_agent.core.instruction_refresh import ConfiguredMcpInstructionCapable + from fast_agent.interfaces import FastAgentLLMProtocol INTERNAL_AGENT_CARD_SENTINEL = "(internal)" @@ -42,6 +44,12 @@ def config(self) -> "AgentConfig": ... def set_instruction(self, instruction: str) -> None: ... +@runtime_checkable +class LlmInstructionContextAgent(InstructionContextAgent, Protocol): + @property + def llm(self) -> "FastAgentLLMProtocol | None": ... + + def _normalize_agent_type_value(value: object) -> str: if value is None: return "" @@ -66,6 +74,18 @@ def _resolve_agent_card_paths(agent: InstructionContextAgent) -> tuple[str, str] return str(resolved), str(resolved.parent) +def _resolve_model_specific(agent: InstructionContextAgent) -> str: + if isinstance(agent, LlmInstructionContextAgent) and agent.llm is not None: + model_params = agent.llm.resolved_model.model_params + if model_params is not None and model_params.model_specific: + return model_params.model_specific + + config_model = agent.config.model + if config_model is None: + return "" + return ModelDatabase.get_model_specific(config_model) + + def build_agent_instruction_context( agent: InstructionContextAgent, base_context: Mapping[str, str] | None = None, @@ -86,6 +106,7 @@ def build_agent_instruction_context( context["agentType"] = agent_type context["agentCardPath"] = card_path context["agentCardDir"] = card_dir + context["model_specific"] = _resolve_model_specific(agent) return context diff --git a/src/fast_agent/core/model_resolution.py b/src/fast_agent/core/model_resolution.py index e4858e97f..d023f3836 100644 --- a/src/fast_agent/core/model_resolution.py +++ b/src/fast_agent/core/model_resolution.py @@ -72,7 +72,7 @@ def _resolve_reference_recursive( if references is None or len(references) == 0: raise ModelConfigError( f"Model reference '{token}' could not be resolved", - "No model_references are configured. Add a model_references section in fastagent.config.yaml.", + "No model_references are configured. Add a model_references section in fast-agent.yaml.", ) if token in stack: diff --git a/src/fast_agent/core/prompt_templates.py b/src/fast_agent/core/prompt_templates.py index d69657bda..bb0023e3d 100644 --- a/src/fast_agent/core/prompt_templates.py +++ b/src/fast_agent/core/prompt_templates.py @@ -142,6 +142,20 @@ def load_skills_for_context( override_dirs.append(override_path) else: override_dirs.append(base_dir / override_path) + else: + from fast_agent.config import get_settings + from fast_agent.paths import default_skill_paths + + settings = get_settings() + settings_for_skills = ( + settings + if settings.environment_dir is not None or settings._fast_agent_home_source != "default" + else None + ) + override_dirs = default_skill_paths( + settings_for_skills, + cwd=base_dir, + ) registry = SkillRegistry(base_dir=base_dir, directories=override_dirs) try: diff --git a/src/fast_agent/event_progress.py b/src/fast_agent/event_progress.py index 44653b892..dd7f7df77 100644 --- a/src/fast_agent/event_progress.py +++ b/src/fast_agent/event_progress.py @@ -12,7 +12,7 @@ class ProgressAction(str, Enum): CONNECTING = "Connecting" LOADED = "Loaded" INITIALIZED = "Initialized" - CHATTING = "Chatting" + SENDING = "Sending" STREAMING = "Streaming" # Special action for real-time streaming updates THINKING = "Thinking" # Special action for real-time thinking updates ROUTING = "Routing" diff --git a/src/fast_agent/home.py b/src/fast_agent/home.py new file mode 100644 index 000000000..f000ede5d --- /dev/null +++ b/src/fast_agent/home.py @@ -0,0 +1,245 @@ +"""fast-agent home and configuration discovery helpers.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Mapping + +from fast_agent.constants import DEFAULT_ENVIRONMENT_DIR +from fast_agent.core.exceptions import ConfigFileError + +HomeSource = Literal["cli", "FAST_AGENT_HOME", "ENVIRONMENT_DIR", "default"] +ConfigSource = Literal["explicit", "home", "cwd", "none"] +SecretsSource = Literal["same_dir", "home", "cwd", "none"] + +PREFERRED_CONFIG_FILENAME = "fast-agent.yaml" +TRANSITIONAL_CONFIG_FILENAMES = ("fast-agent.config.yaml",) +LEGACY_CONFIG_FILENAMES = ("fastagent.config.yaml",) +CONFIG_FILENAMES = ( + PREFERRED_CONFIG_FILENAME, + *TRANSITIONAL_CONFIG_FILENAMES, + *LEGACY_CONFIG_FILENAMES, +) + +PREFERRED_SECRETS_FILENAME = "fast-agent.secrets.yaml" +LEGACY_SECRETS_FILENAMES = ("fastagent.secrets.yaml",) +SECRETS_FILENAMES = (PREFERRED_SECRETS_FILENAME, *LEGACY_SECRETS_FILENAMES) + + +@dataclass(frozen=True, slots=True) +class FastAgentHome: + path: Path + source: HomeSource + + +@dataclass(frozen=True, slots=True) +class ConfigDiscoveryResult: + home: FastAgentHome | None + config_path: Path | None + secrets_path: Path | None + config_source: ConfigSource + secrets_source: SecretsSource + + +class ConfigDiscoveryError(ConfigFileError): + """Base class for fast-agent config discovery failures.""" + + +class AmbiguousConfigFilesError(ConfigDiscoveryError): + """Raised when multiple supported config aliases exist in one directory.""" + + def __init__(self, directory: Path, candidates: tuple[Path, ...]) -> None: + self.directory = directory + self.candidates = candidates + super().__init__(_format_ambiguity("config", directory, candidates)) + + +class AmbiguousSecretsFilesError(ConfigDiscoveryError): + """Raised when multiple supported secrets aliases exist in one directory.""" + + def __init__(self, directory: Path, candidates: tuple[Path, ...]) -> None: + self.directory = directory + self.candidates = candidates + super().__init__(_format_ambiguity("secrets", directory, candidates)) + + +def resolve_fast_agent_home( + *, + cwd: Path | None = None, + cli_override: str | Path | None = None, + noenv: bool = False, +) -> FastAgentHome | None: + """Resolve the active fast-agent home. + + Precedence: ``--env``/``cli_override`` > ``FAST_AGENT_HOME`` > + ``ENVIRONMENT_DIR`` > ``./.fast-agent``. ``noenv`` disables home selection. + """ + if noenv: + return None + + base = _resolve_cwd(cwd) + if cli_override is not None: + return FastAgentHome(_resolve_path(cli_override, base), "cli") + + fast_agent_home = os.getenv("FAST_AGENT_HOME") + if fast_agent_home: + return FastAgentHome(_resolve_path(fast_agent_home, base), "FAST_AGENT_HOME") + + legacy_environment_dir = os.getenv("ENVIRONMENT_DIR") + if legacy_environment_dir: + return FastAgentHome(_resolve_path(legacy_environment_dir, base), "ENVIRONMENT_DIR") + + return FastAgentHome((base / DEFAULT_ENVIRONMENT_DIR).resolve(), "default") + + +def discover_config_files( + *, + cwd: Path | None = None, + home: FastAgentHome | None = None, + explicit_config_path: str | Path | None = None, +) -> ConfigDiscoveryResult: + """Discover config and secrets files without parent-directory walking.""" + base = _resolve_cwd(cwd) + + if explicit_config_path is not None: + config_path = _resolve_path(explicit_config_path, base) + secrets_path = find_secrets_in_directory(config_path.parent) + return ConfigDiscoveryResult( + home=home, + config_path=config_path, + secrets_path=secrets_path, + config_source="explicit", + secrets_source="same_dir" if secrets_path else "none", + ) + + searched: set[Path] = set() + if home is not None: + home_dir = home.path.resolve() + searched.add(home_dir) + config_path = find_config_in_directory(home_dir) + if config_path is not None: + secrets_path = find_secrets_in_directory(config_path.parent) + return ConfigDiscoveryResult( + home=home, + config_path=config_path, + secrets_path=secrets_path, + config_source="home", + secrets_source="same_dir" if secrets_path else "none", + ) + + cwd_dir = base.resolve() + if cwd_dir not in searched: + config_path = find_config_in_directory(cwd_dir) + if config_path is not None: + secrets_path = find_secrets_in_directory(config_path.parent) + return ConfigDiscoveryResult( + home=home, + config_path=config_path, + secrets_path=secrets_path, + config_source="cwd", + secrets_source="same_dir" if secrets_path else "none", + ) + + if home is not None: + secrets_path = find_secrets_in_directory(home.path) + if secrets_path is not None: + return ConfigDiscoveryResult( + home=home, + config_path=None, + secrets_path=secrets_path, + config_source="none", + secrets_source="home", + ) + + if cwd_dir not in searched: + secrets_path = find_secrets_in_directory(cwd_dir) + if secrets_path is not None: + return ConfigDiscoveryResult( + home=home, + config_path=None, + secrets_path=secrets_path, + config_source="none", + secrets_source="cwd", + ) + + return ConfigDiscoveryResult( + home=home, + config_path=None, + secrets_path=None, + config_source="none", + secrets_source="none", + ) + + +def find_config_in_directory(directory: Path) -> Path | None: + """Return the single supported config file in ``directory``, or raise on ambiguity.""" + candidates = _existing_files(directory, CONFIG_FILENAMES) + if len(candidates) > 1: + raise AmbiguousConfigFilesError(directory.resolve(), candidates) + return candidates[0] if candidates else None + + +def find_secrets_in_directory(directory: Path) -> Path | None: + """Return the single supported secrets file in ``directory``, or raise on ambiguity.""" + candidates = _existing_files(directory, SECRETS_FILENAMES) + if len(candidates) > 1: + raise AmbiguousSecretsFilesError(directory.resolve(), candidates) + return candidates[0] if candidates else None + + +def build_child_environment( + *, + active_home: str | Path | None, + noenv: bool = False, + base: Mapping[str, str] | None = None, + overrides: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Build an environment for shell/MCP child processes. + + ``FAST_AGENT_RUNTIME_ENVIRONMENT`` is the documented runtime export. + ``ENVIRONMENT_DIR`` is exported alongside it as a legacy compatibility alias. + In ``--noenv`` mode both are removed, including from explicit overrides. + """ + from fast_agent.constants import FAST_AGENT_RUNTIME_ENVIRONMENT + + env = dict(os.environ if base is None else base) + if not noenv and active_home is not None: + home = str(Path(active_home).expanduser().resolve()) + env[FAST_AGENT_RUNTIME_ENVIRONMENT] = home + env["ENVIRONMENT_DIR"] = home + + if overrides: + env.update(overrides) + + if noenv: + env.pop(FAST_AGENT_RUNTIME_ENVIRONMENT, None) + env.pop("ENVIRONMENT_DIR", None) + + return env + + +def _existing_files(directory: Path, filenames: tuple[str, ...]) -> tuple[Path, ...]: + resolved_dir = directory.expanduser().resolve() + return tuple(path for filename in filenames if (path := resolved_dir / filename).is_file()) + + +def _resolve_cwd(cwd: Path | None) -> Path: + return (cwd or Path.cwd()).expanduser().resolve() + + +def _resolve_path(path: str | Path, cwd: Path) -> Path: + resolved = Path(path).expanduser() + if not resolved.is_absolute(): + resolved = cwd / resolved + return resolved.resolve() + + +def _format_ambiguity(kind: str, directory: Path, candidates: tuple[Path, ...]) -> str: + names = "\n".join(f"- {candidate.name}" for candidate in candidates) + return ( + f"Multiple fast-agent {kind} files found in {directory}:\n" + f"{names}\n\n" + f"Please keep only one {kind} file in this directory." + ) diff --git a/src/fast_agent/interfaces.py b/src/fast_agent/interfaces.py index ce22b0b15..d00a8eb9e 100644 --- a/src/fast_agent/interfaces.py +++ b/src/fast_agent/interfaces.py @@ -99,6 +99,12 @@ async def structured_schema( request_params: RequestParams | None = None, ) -> tuple[Any | None, PromptMessageExtended]: ... + def parse_structured_schema_response( + self, + message: PromptMessageExtended, + schema: dict[str, Any], + ) -> tuple[Any | None, PromptMessageExtended]: ... + async def structured( self, messages: list[PromptMessageExtended], @@ -122,6 +128,11 @@ def get_request_params( request_params: RequestParams | None = None, ) -> RequestParams: ... + def resolve_structured_tool_policy( + self, + request_params: RequestParams, + ) -> Literal["always", "defer", "no_tools"]: ... + default_request_params: RequestParams instruction: str | None diff --git a/src/fast_agent/llm/fastagent_llm.py b/src/fast_agent/llm/fastagent_llm.py index d1318ee37..9cff7f7b9 100644 --- a/src/fast_agent/llm/fastagent_llm.py +++ b/src/fast_agent/llm/fastagent_llm.py @@ -114,6 +114,7 @@ class FastAgentLLM(ContextDependent, FastAgentLLMProtocol, Generic[MessageParamT PARAM_STREAMING_TIMEOUT = "streaming_timeout" PARAM_SERVICE_TIER = "service_tier" PARAM_STRUCTURED_SCHEMA = "structured_schema" + PARAM_STRUCTURED_TOOL_POLICY = "structured_tool_policy" # Base set of fields that should always be excluded BASE_EXCLUDE_FIELDS = { @@ -124,6 +125,7 @@ class FastAgentLLM(ContextDependent, FastAgentLLMProtocol, Generic[MessageParamT PARAM_STREAMING_TIMEOUT, PARAM_SERVICE_TIER, PARAM_STRUCTURED_SCHEMA, + PARAM_STRUCTURED_TOOL_POLICY, } """ @@ -180,6 +182,7 @@ def __init__( self._provider = provider # memory contains provider specific API types. self.history: Memory[MessageParamT] = SimpleMemory[MessageParamT]() + self._structured_tool_defer_info_logged = False # Initialize the display component from fast_agent.ui.console_display import ConsoleDisplay @@ -302,6 +305,86 @@ def _get_model_json_mode(self, model_name: str | None) -> str | None: params = self._get_model_params(model_name) return params.json_mode if params is not None else None + def _get_model_structured_tool_policy( + self, model_name: str | None + ) -> Literal["always", "defer", "no_tools"] | None: + params = self._get_model_params(model_name) + return params.structured_tool_policy if params is not None else None + + def _default_structured_tool_policy( + self, model_name: str | None + ) -> Literal["always", "defer", "no_tools"]: + del model_name + return "always" + + def _resolve_structured_tool_policy( + self, + request_params: RequestParams, + ) -> Literal["always", "defer", "no_tools"]: + policy = request_params.structured_tool_policy + if policy != "auto": + return policy + + model_name = request_params.model or self.default_request_params.model or self._model_name + model_policy = self._get_model_structured_tool_policy(model_name) + if model_policy is not None: + return model_policy + return self._default_structured_tool_policy(model_name) + + def resolve_structured_tool_policy( + self, + request_params: RequestParams, + ) -> Literal["always", "defer", "no_tools"]: + return self._resolve_structured_tool_policy(request_params) + + def _should_defer_structured_schema_for_tools( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams, + tools: list[Tool] | None, + ) -> bool: + return self._should_suppress_structured_schema_for_tools(messages, request_params, tools) + + def _has_tool_results(self, messages: list[PromptMessageExtended]) -> bool: + return any(message.tool_results for message in messages) + + def _has_structured_intent(self, request_params: RequestParams) -> bool: + return ( + request_params.structured_schema is not None + or request_params.response_format is not None + ) + + def _should_suppress_structured_schema_for_tools( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams, + tools: list[Tool] | None, + ) -> bool: + return ( + request_params.structured_schema is not None + and bool(tools) + and self._resolve_structured_tool_policy(request_params) == "defer" + and not self._has_tool_results(messages) + ) + + def _should_suppress_tools_for_structured_final( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams, + tools: list[Tool] | None, + ) -> bool: + return ( + self._has_structured_intent(request_params) + and bool(tools) + and ( + self._resolve_structured_tool_policy(request_params) == "no_tools" + or ( + self._resolve_structured_tool_policy(request_params) == "defer" + and self._has_tool_results(messages) + ) + ) + ) + def _get_model_context_window(self, model_name: str | None) -> int | None: params = self._get_model_params(model_name) return params.context_window if params is not None else None @@ -347,9 +430,7 @@ def _get_model_anthropic_web_fetch_version(self, model_name: str | None) -> str params = self._get_model_params(model_name) return params.anthropic_web_fetch_version if params is not None else None - def _get_model_anthropic_required_betas( - self, model_name: str | None - ) -> tuple[str, ...] | None: + def _get_model_anthropic_required_betas(self, model_name: str | None) -> tuple[str, ...] | None: params = self._get_model_params(model_name) return params.anthropic_required_betas if params is not None else None @@ -463,7 +544,9 @@ def _get_provider_config(self) -> Any | None: ) def _provider_config_sections(self) -> tuple[str, ...]: - section_name = getattr(self, "config_section", None) or getattr(self.provider, "value", None) + section_name = getattr(self, "config_section", None) or getattr( + self.provider, "value", None + ) return (section_name,) if section_name else () def _provider_config_fallback_sections(self) -> tuple[str, ...]: @@ -596,7 +679,9 @@ def _is_fatal_error(e: Exception) -> bool: paused_progress = progress_display.paused() with paused_progress: - error_console.print(f"\n[yellow]▲ Provider Error: {str(e)[:300]}...[/yellow]") + error_console.print( + f"\n[yellow]▲ Provider Error: {str(e)[:300]}...[/yellow]" + ) error_console.print( f"[dim]⟳ Retrying in {wait_time}s... (Attempt {attempt + 1}/{retries})[/dim]" ) @@ -691,6 +776,44 @@ async def generate( final_request_params, tools, ) + prepared_tools = tools + suppress_final_tools = self._should_suppress_tools_for_structured_final( + prepared_messages, + prepared_request_params, + tools, + ) + if suppress_final_tools: + prepared_tools = None + suppress_schema = self._should_suppress_structured_schema_for_tools( + messages, + final_request_params, + tools, + ) + if suppress_schema or suppress_final_tools: + policy = self._resolve_structured_tool_policy(final_request_params) + model_name = ( + prepared_request_params.model + or self.default_request_params.model + or self._model_name + ) + if policy == "defer" and not self._structured_tool_defer_info_logged: + self.logger.info( + "Model/provider does not reliably support tools and structured output " + "in the same request; using two-phase structured tool flow: tools first, " + "schema-only final answer." + ) + self._structured_tool_defer_info_logged = True + self.logger.debug( + "structured_tools_policy", + data={ + "model": model_name, + "json_mode": self._get_model_json_mode(model_name), + "structured_tool_policy": policy, + "phase": "structured_final" if suppress_final_tools else "tool_selection", + "suppressed_schema": suppress_schema, + "suppressed_tools": suppress_final_tools, + }, + ) # Store MCP metadata in context variable if prepared_request_params.mcp_metadata: @@ -702,7 +825,10 @@ async def generate( timing_capture, cleanup_timing_capture = self._start_request_timing_capture() try: assistant_response = await self._execute_with_retry( - self._apply_prompt_provider_specific, full_history, prepared_request_params, tools + self._apply_prompt_provider_specific, + full_history, + prepared_request_params, + prepared_tools, ) finally: cleanup_timing_capture() @@ -802,20 +928,44 @@ async def structured( Returns: Tuple of (parsed model instance or None, assistant response message) """ - schema = validate_json_schema_definition(model.model_json_schema()) - parsed_json, assistant_response = await self.structured_schema( - messages, - schema, - request_params, - ) - if parsed_json is None: - return None, assistant_response + + final_request_params = self.get_request_params(request_params) + if final_request_params.mcp_metadata: + _mcp_metadata_var.set(final_request_params.mcp_metadata) + + timing_capture, cleanup_timing_capture = self._start_request_timing_capture() try: - return model.model_validate(parsed_json), assistant_response - except Exception as e: - logger = get_logger(__name__) - logger.warning(f"Failed to validate structured response: {str(e)}") - return None, assistant_response + result_or_response = await self._execute_with_retry( + self._apply_prompt_provider_specific_structured, + messages, + model, + final_request_params, + on_final_error=self._handle_retry_failure, + ) + finally: + cleanup_timing_capture() + + if isinstance(result_or_response, PromptMessageExtended): + result, assistant_response = self._structured_from_multipart( + result_or_response, + model, + ) + else: + result, assistant_response = result_or_response + + end_time = time.perf_counter() + self._add_timing_channel( + assistant_response, + timing_capture.start_time, + end_time, + ttft_ms=timing_capture.ttft_ms, + time_to_response_ms=timing_capture.time_to_response_ms, + ) + + self.usage_accumulator.count_tools(len(assistant_response.tool_calls or {})) + self._append_usage_channel(assistant_response) + + return result, assistant_response async def structured_schema( self, @@ -841,11 +991,19 @@ async def structured_schema( ) assistant_response = await self.generate(messages, final_request_params) - return self._structured_schema_from_multipart( + return self.parse_structured_schema_response( assistant_response, normalized_schema, ) + def parse_structured_schema_response( + self, + message: PromptMessageExtended, + schema: dict[str, Any], + ) -> tuple[Any | None, PromptMessageExtended]: + """Parse and validate an assistant response against a raw JSON Schema.""" + return self._structured_schema_from_multipart(message, schema) + @staticmethod def model_to_response_format( model: Type[Any], @@ -1095,7 +1253,7 @@ def _log_chat_progress(self, chat_turn: int | None = None, model: str | None = N # Use verb directly regardless of type act = self.verb else: - act = ProgressAction.CHATTING + act = ProgressAction.SENDING data = { "progress_action": act, diff --git a/src/fast_agent/llm/hf_inference_lookup.py b/src/fast_agent/llm/hf_inference_lookup.py index d31d1d9d3..2722427d1 100644 --- a/src/fast_agent/llm/hf_inference_lookup.py +++ b/src/fast_agent/llm/hf_inference_lookup.py @@ -139,7 +139,7 @@ async def lookup_inference_providers( """Look up available inference providers for a HuggingFace model. Args: - model_id: The HuggingFace model ID (e.g., "moonshotai/Kimi-K2-Thinking") + model_id: The HuggingFace model ID (e.g., "deepseek-ai/DeepSeek-V4-Pro") timeout: Request timeout in seconds lookup_fn: Optional function to use for lookup (for testing) @@ -147,7 +147,7 @@ async def lookup_inference_providers( InferenceProviderLookupResult with provider information Example: - >>> result = await lookup_inference_providers("moonshotai/Kimi-K2-Thinking") + >>> result = await lookup_inference_providers("deepseek-ai/DeepSeek-V4-Pro") >>> if result.has_providers: ... print(f"Available providers: {result.format_provider_list()}") ... for model_str in result.format_model_strings(): @@ -296,7 +296,7 @@ async def validate_hf_model( """Validate that an HF model exists and has inference providers. Args: - model: The model string (e.g., "hf.moonshotai/Kimi-K2-Thinking:together") + model: The model string (e.g., "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai") Can also be an alias like "kimi" or "glm" that resolves to an HF model. presets: Optional dict of model presets (e.g., {"kimi": "hf.moonshotai/..."}). If not provided, no preset resolution is performed. diff --git a/src/fast_agent/llm/model_database.py b/src/fast_agent/llm/model_database.py index 26044ec53..e9c77d652 100644 --- a/src/fast_agent/llm/model_database.py +++ b/src/fast_agent/llm/model_database.py @@ -36,6 +36,9 @@ class ModelParameters(BaseModel): json_mode: None | str = "schema" """Structured output style. 'schema', 'object' or None for unsupported """ + structured_tool_policy: Literal["always", "defer", "no_tools"] | None = None + """Default structured-output/regular-tool coexistence policy for this model.""" + reasoning: None | str = None """Reasoning output style. 'tags' if enclosed in tags, 'none' if not used""" @@ -84,6 +87,9 @@ class ModelParameters(BaseModel): default_provider: Provider | None = None """Default provider used when model is referenced without an explicit prefix.""" + model_specific: str | None = None + """Optional model-specific system prompt text for {{model_specific}}.""" + fast: bool = False """Whether this model is recommended for fast/simple tasks.""" @@ -103,6 +109,13 @@ class ModelDatabase: _RUNTIME_MODEL_DEFAULT_PROVIDERS: dict[str, Provider] = {} _RUNTIME_MODEL_PARAMS: dict[str, ModelParameters] = {} + REMOVED_MODEL_NAMES: frozenset[str] = frozenset( + { + "claude-3-haiku-20240307", + "claude-3-5-sonnet-20241022", + "claude-3-7-sonnet-20250219", + } + ) # Common parameter sets OPENAI_MULTIMODAL = [ @@ -151,8 +164,13 @@ class ModelDatabase: "video/webm", ] QWEN_MULTIMODAL = ["text/plain", "image/jpeg", "image/png", "image/webp"] - XAI_VISION = ["text/plain", "image/jpeg", "image/png", "image/webp"] + XAI_VISION = ["text/plain", "image/jpeg", "image/png"] TEXT_ONLY = ["text/plain"] + # encourage commentary + GPT_53_PLUS_MODEL_SPECIFIC = ( + "Before making tool calls, send a brief preamble to the user " + "explaining what you’re about to do." + ) OPENAI_O_CLASS_REASONING = ReasoningEffortSpec( kind="effort", @@ -351,11 +369,12 @@ class ModelDatabase: context_window=131072, max_output_tokens=32766, tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", + structured_tool_policy="no_tools", reasoning="gpt_oss", ) OPENAI_GPT_5 = ModelParameters( - context_window=400000, + context_window=400000 - 128000, max_output_tokens=128000, tokenizes=OPENAI_MULTIMODAL, reasoning="openai", @@ -366,7 +385,7 @@ class ModelDatabase: ) OPENAI_GPT_5_2 = ModelParameters( - context_window=400000, + context_window=400000 - 128000, max_output_tokens=128000, tokenizes=OPENAI_MULTIMODAL, reasoning="openai", @@ -377,7 +396,7 @@ class ModelDatabase: ) OPENAI_GPT_CODEX = ModelParameters( - context_window=400000, + context_window=400000 - 128000, max_output_tokens=128000, tokenizes=OPENAI_MULTIMODAL, reasoning="openai", @@ -422,6 +441,7 @@ class ModelDatabase: response_service_tiers=("fast",), default_provider=Provider.RESPONSES, reasoning="openai", + model_specific=GPT_53_PLUS_MODEL_SPECIFIC, ) ANTHROPIC_OPUS_4_VERSIONED = ModelParameters( @@ -548,6 +568,7 @@ class ModelDatabase: context_window=1_048_576, max_output_tokens=65_536, tokenizes=GOOGLE_MULTIMODAL, + json_mode="schema", reasoning="google_thinking", reasoning_effort_spec=GOOGLE_THINKING_EFFORT_SPEC, default_provider=Provider.GOOGLE, @@ -557,22 +578,25 @@ class ModelDatabase: context_window=1_048_576, max_output_tokens=8192, tokenizes=GOOGLE_MULTIMODAL, + json_mode="schema", default_provider=Provider.GOOGLE, ) - # 31/08/25 switched to object mode (even though groq says schema supported and used to work..) - KIMI_MOONSHOT = ModelParameters( + KIMI_MOONSHOT_INSTRUCT = ModelParameters( context_window=262144, max_output_tokens=16384, tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", + default_provider=Provider.HUGGINGFACE, ) KIMI_MOONSHOT_THINKING = ModelParameters( context_window=262144, max_output_tokens=16384, tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", + structured_tool_policy="no_tools", reasoning="reasoning_content", + default_provider=Provider.HUGGINGFACE, ) KIMI_MOONSHOT_25 = ModelParameters( context_window=262144, @@ -581,6 +605,8 @@ class ModelDatabase: json_mode="schema", reasoning="reasoning_content", reasoning_effort_spec=KIMI_REASONING_TOGGLE_SPEC, + default_provider=Provider.HUGGINGFACE, + model_specific="You have vision capabilities.", ) KIMI_MOONSHOT_26 = ModelParameters( context_window=262144, @@ -589,22 +615,35 @@ class ModelDatabase: # supported in Moonshot's official API for now. tokenizes=OPENAI_VISION, json_mode="schema", + structured_tool_policy="no_tools", reasoning="reasoning_content", reasoning_effort_spec=KIMI_REASONING_TOGGLE_SPEC, + default_provider=Provider.HUGGINGFACE, + model_specific="You have vision capabilities.", ) - # FIXME: xAI has not documented the max output tokens for Grok 4. Using Grok 3 as a placeholder. Will need to update when available (if ever) + + # xAI recommends Grok 4.3 for general text workloads. The pricing/tool + # invocation tables and file/collection storage pricing are billing policy, + # not model capability metadata, so they are intentionally not encoded here. + # xAI has not documented the max output tokens for Grok 4.x; keep the prior + # Grok 3-derived placeholder until an official per-model value is published. GROK_4 = ModelParameters( context_window=256000, max_output_tokens=16385, tokenizes=TEXT_ONLY, default_provider=Provider.XAI, + response_transports=("sse", "websocket"), + response_websocket_providers=(Provider.XAI, Provider.XAI_RESPONSES), ) + GROK_43 = GROK_4.model_copy(update={"context_window": 1_000_000}) GROK_4_VLM = ModelParameters( context_window=2000000, max_output_tokens=16385, tokenizes=XAI_VISION, default_provider=Provider.XAI, + response_transports=("sse", "websocket"), + response_websocket_providers=(Provider.XAI, Provider.XAI_RESPONSES), ) # Source for Grok 3 max output: https://www.reddit.com/r/grok/comments/1j7209p/exploring_grok_3_beta_output_capacity_a_simple/ @@ -621,7 +660,7 @@ class ModelDatabase: context_window=202752, max_output_tokens=8192, tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", reasoning="reasoning_content", stream_mode="manual", ) @@ -630,7 +669,7 @@ class ModelDatabase: context_window=202752, max_output_tokens=65536, # default from https://docs.z.ai/guides/overview/concept-param#token-usage-calculation - max is 131072 tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", reasoning="reasoning_content", reasoning_effort_spec=GLM_REASONING_TOGGLE_SPEC, stream_mode="manual", @@ -640,7 +679,7 @@ class ModelDatabase: context_window=202800, max_output_tokens=131072, tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", reasoning="reasoning_content", reasoning_effort_spec=GLM_REASONING_TOGGLE_SPEC, stream_mode="manual", @@ -650,7 +689,7 @@ class ModelDatabase: context_window=202752, max_output_tokens=131072, tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", reasoning="reasoning_content", stream_mode="manual", ) @@ -658,23 +697,49 @@ class ModelDatabase: context_window=202752, max_output_tokens=131072, tokenizes=TEXT_ONLY, - json_mode="object", + json_mode="schema", + structured_tool_policy="no_tools", reasoning="reasoning_content", reasoning_effort_spec=GLM_REASONING_TOGGLE_SPEC, stream_mode="manual", ) + MINIMAX_27 = ModelParameters( + context_window=192200, + max_output_tokens=131072, + tokenizes=TEXT_ONLY, + json_mode="schema", + structured_tool_policy="no_tools", + reasoning="reasoning_content", + stream_mode="manual", + ) HF_PROVIDER_DEEPSEEK31 = ModelParameters( - context_window=163_800, max_output_tokens=8192, tokenizes=TEXT_ONLY + context_window=163_800, + max_output_tokens=8192, + tokenizes=TEXT_ONLY, + json_mode="schema", + structured_tool_policy="no_tools", ) HF_PROVIDER_DEEPSEEK32 = ModelParameters( context_window=163_800, max_output_tokens=8192, tokenizes=TEXT_ONLY, + json_mode="schema", + structured_tool_policy="no_tools", reasoning="gpt_oss", ) + HF_PROVIDER_DEEPSEEK4_PRO = ModelParameters( + context_window=1_048_576, + max_output_tokens=393_216, + tokenizes=TEXT_ONLY, + json_mode="schema", + structured_tool_policy="no_tools", + reasoning="reasoning_content", + default_provider=Provider.HUGGINGFACE, + ) + HF_PROVIDER_QWEN3_NEXT = ModelParameters( context_window=262_000, max_output_tokens=8192, tokenizes=TEXT_ONLY ) @@ -683,6 +748,8 @@ class ModelDatabase: context_window=262_144, max_output_tokens=65_536, tokenizes=QWEN_MULTIMODAL, + json_mode="schema", + structured_tool_policy="no_tools", reasoning="reasoning_content", reasoning_effort_spec=GLM_REASONING_TOGGLE_SPEC, default_provider=Provider.HUGGINGFACE, @@ -742,20 +809,36 @@ class ModelDatabase: "gpt-5-nano": _with_fast(OPENAI_GPT_5), "gpt-5-nano-2025-08-07": _with_fast(OPENAI_GPT_5), "gpt-5.1": OPENAI_GPT_5_2, - "gpt-5.1-codex": OPENAI_GPT_CODEX.model_copy(update={"response_service_tiers": ("fast",)}), - "gpt-5.2-codex": OPENAI_GPT_CODEX, - "gpt-5.3-codex": OPENAI_GPT_CODEX.model_copy(update={"response_service_tiers": ("fast",)}), + "gpt-5.3-codex": OPENAI_GPT_CODEX.model_copy( + update={ + "response_service_tiers": ("fast",), + "model_specific": GPT_53_PLUS_MODEL_SPECIFIC, + } + ), "gpt-5.4": OPENAI_GPT_CODEX.model_copy( - update={"reasoning_effort_spec": OPENAI_GPT_51_CLASS_REASONING} + update={ + "reasoning_effort_spec": OPENAI_GPT_51_CLASS_REASONING, + "model_specific": GPT_53_PLUS_MODEL_SPECIFIC, + } ), "gpt-5.5": OPENAI_GPT_CODEX.model_copy( - update={"reasoning_effort_spec": OPENAI_GPT_51_CLASS_REASONING} + update={ + "reasoning_effort_spec": OPENAI_GPT_51_CLASS_REASONING, + "model_specific": GPT_53_PLUS_MODEL_SPECIFIC, + } + ), + "gpt-5.4-mini": OPENAI_GPT_54_SMALL.model_copy( + update={"model_specific": GPT_53_PLUS_MODEL_SPECIFIC} ), - "gpt-5.4-mini": OPENAI_GPT_54_SMALL, "gpt-5.4-nano": OPENAI_GPT_54_SMALL.model_copy( - update={"response_websocket_providers": (Provider.RESPONSES,)} + update={ + "response_websocket_providers": (Provider.RESPONSES,), + "model_specific": GPT_53_PLUS_MODEL_SPECIFIC, + } + ), + "gpt-5.3-codex-spark": _with_fast( + OPENAI_GPT_CODEX_SPARK.model_copy(update={"model_specific": GPT_53_PLUS_MODEL_SPECIFIC}) ), - "gpt-5.3-codex-spark": _with_fast(OPENAI_GPT_CODEX_SPARK), "gpt-5.2": OPENAI_GPT_5_2.model_copy( update={ "response_transports": ("sse", "websocket"), @@ -764,23 +847,9 @@ class ModelDatabase: ), "gpt-5.3-chat-latest": _with_fast(params=OPENAI_CHAT53_INSTANT), # Anthropic Models - "claude-3-haiku": ANTHROPIC_35_SERIES, - "claude-3-haiku-20240307": ANTHROPIC_LEGACY, - "claude-3-sonnet": ANTHROPIC_LEGACY, - "claude-3-opus": ANTHROPIC_LEGACY, - "claude-3-opus-20240229": ANTHROPIC_LEGACY, - "claude-3-opus-latest": ANTHROPIC_LEGACY, "claude-3-5-haiku": ANTHROPIC_35_SERIES, "claude-3-5-haiku-20241022": ANTHROPIC_35_SERIES, "claude-3-5-haiku-latest": _with_fast(ANTHROPIC_35_SERIES), - "claude-3-sonnet-20240229": ANTHROPIC_LEGACY, - "claude-3-5-sonnet": ANTHROPIC_35_SERIES, - "claude-3-5-sonnet-20240620": ANTHROPIC_35_SERIES, - "claude-3-5-sonnet-20241022": ANTHROPIC_35_SERIES, - "claude-3-5-sonnet-latest": ANTHROPIC_35_SERIES, - "claude-3-7-sonnet": ANTHROPIC_37_SERIES_THINKING, - "claude-3-7-sonnet-20250219": ANTHROPIC_37_SERIES_THINKING, - "claude-3-7-sonnet-latest": ANTHROPIC_37_SERIES_THINKING, "claude-sonnet-4-0": _with_long_context( ANTHROPIC_SONNET_4_LEGACY, ANTHROPIC_LONG_CONTEXT_WINDOW ), @@ -806,31 +875,31 @@ class ModelDatabase: "deepseek-chat": _with_fast(DEEPSEEK_CHAT_STANDARD), # Google Gemini Models (vanilla aliases and versioned) "gemini-2.0-flash": _with_fast(GEMINI_2_FLASH), - "gemini-2.5-flash-preview": GEMINI_STANDARD, - "gemini-2.5-pro-preview": GEMINI_STANDARD, - "gemini-2.5-flash-preview-05-20": GEMINI_STANDARD, - "gemini-2.5-pro-preview-05-06": GEMINI_STANDARD, "gemini-2.5-pro": GEMINI_STANDARD, - "gemini-2.5-flash-preview-09-2025": _with_fast(GEMINI_STANDARD), "gemini-2.5-flash": _with_fast(GEMINI_STANDARD), "gemini-3-pro-preview": GEMINI_STANDARD, "gemini-3-flash-preview": GEMINI_STANDARD, "gemini-3.1-pro-preview": GEMINI_STANDARD, + "gemini-3.1-flash-lite-preview": _with_fast(GEMINI_STANDARD), # xAI Grok Models + "grok": GROK_43, + "grok-4.3": GROK_43, + "grok-4.3-latest": GROK_43, "grok-4-1-fast-reasoning": GROK_4_VLM, "grok-4-1-fast-non-reasoning": GROK_4_VLM, "grok-4-fast-reasoning": GROK_4_VLM, "grok-4-fast-non-reasoning": GROK_4_VLM, - "grok-4": GROK_4, + "grok-4": GROK_43, + "grok-4-latest": GROK_43, "grok-4-0709": GROK_4, "grok-3": GROK_3, + "grok-3-latest": GROK_3, "grok-3-mini": GROK_3, "grok-3-fast": GROK_3, "grok-3-mini-fast": _with_fast(GROK_3), - "moonshotai/kimi-k2": KIMI_MOONSHOT, - "moonshotai/kimi-k2-instruct-0905": _with_fast(KIMI_MOONSHOT), + "moonshotai/kimi-k2": _with_fast(KIMI_MOONSHOT_INSTRUCT), + "moonshotai/kimi-k2-instruct-0905": _with_fast(KIMI_MOONSHOT_INSTRUCT), "moonshotai/kimi-k2-thinking": KIMI_MOONSHOT_THINKING, - "moonshotai/kimi-k2-thinking-0905": KIMI_MOONSHOT_THINKING, "moonshotai/kimi-k2.5": KIMI_MOONSHOT_25, "moonshotai/kimi-k2.6": KIMI_MOONSHOT_26, "qwen/qwen3-32b": QWEN3_REASONER, @@ -840,14 +909,18 @@ class ModelDatabase: "zai-org/glm-4.6": GLM_46, "zai-org/glm-4.7": GLM_47, "zai-org/glm-5": _with_fast(GLM_5), - "zai-org/glm-5.1": _with_fast(GLM_5), + "zai-org/glm-5.1": _with_fast( + GLM_5.model_copy(update={"structured_tool_policy": "no_tools"}) + ), "minimaxai/minimax-m2": GLM_46, "minimaxai/minimax-m2.1": MINIMAX_21, "minimaxai/minimax-m2.5": MINIMAX_25, + "minimaxai/minimax-m2.7": MINIMAX_27, "qwen/qwen3-next-80b-a3b-instruct": HF_PROVIDER_QWEN3_NEXT, "qwen/qwen3.5-397b-a17b": HF_PROVIDER_QWEN35, "deepseek-ai/deepseek-v3.1": HF_PROVIDER_DEEPSEEK31, "deepseek-ai/deepseek-v3.2": HF_PROVIDER_DEEPSEEK32, + "deepseek-ai/deepseek-v4-pro": HF_PROVIDER_DEEPSEEK4_PRO, # aliyun modern "qwen3-max": ALIYUN_QWEN3_MODERN, } @@ -867,6 +940,8 @@ def get_model_params( effective_provider = provider or cls.get_default_provider(model) normalized = cls.normalize_model_name(model) + if normalized in cls.REMOVED_MODEL_NAMES: + return None if effective_provider is not None: provider_override = cls._PROVIDER_MODEL_OVERRIDES.get((effective_provider, normalized)) if provider_override is not None: @@ -948,6 +1023,12 @@ def get_tokenizes(cls, model: str, *, provider: Provider | None = None) -> list[ params = cls.get_model_params(model, provider=provider) return params.tokenizes if params else None + @classmethod + def get_model_specific(cls, model: str, *, provider: Provider | None = None) -> str: + """Get optional model-specific system prompt text for a model.""" + params = cls.get_model_params(model, provider=provider) + return params.model_specific if params and params.model_specific else "" + @classmethod def supports_mime( cls, @@ -1248,6 +1329,20 @@ def _provider_from_explicit_prefix(cls, model_spec: str) -> Provider | None: return None + @classmethod + def _model_name_without_explicit_prefix(cls, model_spec: str) -> str: + if "/" in model_spec: + prefix, rest = model_spec.split("/", 1) + if rest and any(prefix == provider.value for provider in Provider): + return rest + + if "." in model_spec: + prefix, rest = model_spec.split(".", 1) + if rest and any(prefix == provider.value for provider in Provider): + return rest + + return model_spec + @classmethod def get_default_provider(cls, model: str | None) -> Provider | None: """Get default provider for a model name.""" @@ -1255,6 +1350,10 @@ def get_default_provider(cls, model: str | None) -> Provider | None: if not model_key: return None + bare_model_key = cls._model_name_without_explicit_prefix(model_key) + if bare_model_key in cls.REMOVED_MODEL_NAMES: + return None + explicit_provider = cls._provider_from_explicit_prefix(model_key) if explicit_provider is not None: return explicit_provider diff --git a/src/fast_agent/llm/model_factory.py b/src/fast_agent/llm/model_factory.py index 99b5cfcec..d678fe190 100644 --- a/src/fast_agent/llm/model_factory.py +++ b/src/fast_agent/llm/model_factory.py @@ -1,7 +1,7 @@ import math from collections.abc import Mapping from dataclasses import dataclass -from typing import Callable, Literal, Self, Type, Union +from typing import Callable, Literal, Self, Type, Union, cast from urllib.parse import parse_qs from pydantic import BaseModel @@ -23,7 +23,7 @@ ) from fast_agent.llm.task_budget import parse_task_budget_tokens, validate_task_budget_tokens from fast_agent.llm.text_verbosity import TextVerbosityLevel, parse_text_verbosity -from fast_agent.types import RequestParams +from fast_agent.types import RequestParams, StructuredToolPolicy # Type alias for LLM classes LLMClass = Union[Type[PassthroughLLM], Type[PlaybackLLM], Type[SilentLLM], Type[SlowLLM], type] @@ -39,6 +39,7 @@ class ModelConfig(BaseModel): reasoning_effort: ReasoningEffortSetting | None = None text_verbosity: TextVerbosityLevel | None = None structured_output_mode: StructuredOutputMode | None = None + structured_tool_policy: StructuredToolPolicy | None = None long_context: bool = False transport: TransportSetting | None = None service_tier: ServiceTierSetting | None = None @@ -62,6 +63,7 @@ class ModelQueryOverrides: instant: bool | None = None text_verbosity: TextVerbosityLevel | None = None structured_output_mode: StructuredOutputMode | None = None + structured_tool_policy: StructuredToolPolicy | None = None long_context: bool = False transport: TransportSetting | None = None service_tier: ServiceTierSetting | None = None @@ -93,6 +95,11 @@ def with_defaults(self, defaults: Self) -> "ModelQueryOverrides": if self.structured_output_mode is not None else defaults.structured_output_mode ), + structured_tool_policy=( + self.structured_tool_policy + if self.structured_tool_policy is not None + else defaults.structured_tool_policy + ), long_context=self.long_context or defaults.long_context, transport=self.transport if self.transport is not None else defaults.transport, service_tier=( @@ -142,6 +149,7 @@ def to_model_config(self) -> ModelConfig: reasoning_effort=self.reasoning_effort, text_verbosity=self.query_overrides.text_verbosity, structured_output_mode=self.query_overrides.structured_output_mode, + structured_tool_policy=self.query_overrides.structured_tool_policy, long_context=self.query_overrides.long_context, transport=self.query_overrides.transport, service_tier=self.query_overrides.service_tier, @@ -237,6 +245,9 @@ def _parse_query_overrides( "reasoning", "verbosity", "structured", + "structured_tools", + "structuredToolPolicy", + "structured_tool_policy", "instant", "context", "transport", @@ -266,6 +277,7 @@ def _parse_query_overrides( reasoning_effort: ReasoningEffortSetting | None = None text_verbosity: TextVerbosityLevel | None = None structured_output_mode: StructuredOutputMode | None = None + structured_tool_policy: StructuredToolPolicy | None = None instant: bool | None = None long_context = False transport: TransportSetting | None = None @@ -302,6 +314,19 @@ def _parse_query_overrides( ) structured_output_mode = parsed_structured + structured_tool_keys = ( + "structured_tools", + "structuredToolPolicy", + "structured_tool_policy", + ) + if any(key in query_params for key in structured_tool_keys): + raw_value = _collect_query_values(query_params, structured_tool_keys)[-1].strip() + if raw_value not in {"auto", "always", "defer", "no_tools"}: + raise ModelConfigError( + f"Invalid structured_tools query value: '{raw_value}' in '{model_spec}'" + ) + structured_tool_policy = cast("StructuredToolPolicy", raw_value) + if "instant" in query_params: raw_value = _collect_query_values(query_params, ("instant",))[-1] instant_setting = parse_reasoning_setting(raw_value) @@ -365,6 +390,7 @@ def _parse_query_overrides( instant=instant, text_verbosity=text_verbosity, structured_output_mode=structured_output_mode, + structured_tool_policy=structured_tool_policy, long_context=long_context, transport=transport, service_tier=service_tier, @@ -520,10 +546,15 @@ def _validate_transport_constraints( if transport not in {"websocket", "auto"}: return - if provider not in {Provider.CODEX_RESPONSES, Provider.RESPONSES}: + if provider not in { + Provider.CODEX_RESPONSES, + Provider.RESPONSES, + Provider.XAI, + Provider.XAI_RESPONSES, + }: raise ModelConfigError( "WebSocket transport is experimental and currently supported only for " - "the codexresponses and responses providers." + "the codexresponses, responses, xai, and xairesponses providers." ) supports_transport = ModelDatabase.supports_response_transport(model_name, "websocket") @@ -581,65 +612,59 @@ class ModelFactory: "codexplan": "codexresponses.gpt-5.5", "codexplan54": "codexresponses.gpt-5.4", "codexplan53": "codexresponses.gpt-5.3-codex", - "codexplan52": "codexresponses.gpt-5.2-codex", - "codexplan51": "codexresponses.gpt-5.1-codex", "codexspark": "codexresponses.gpt-5.3-codex-spark", "sonnet": "claude-sonnet-4-6", - "sonnet4": "claude-sonnet-4-0", - "sonnet45": "claude-sonnet-4-5", + "sonnet4": "claude-sonnet-4-6", "sonnet46": "claude-sonnet-4-6", - "sonnet35": "claude-3-5-sonnet-latest", - "sonnet37": "claude-3-7-sonnet-latest", "claude": "claude-sonnet-4-6", "haiku": "claude-haiku-4-5", - "haiku3": "claude-3-haiku-20240307", - "haiku35": "claude-3-5-haiku-latest", "haiku45": "claude-haiku-4-5", "opus": "claude-opus-4-7", - "opus4": "claude-opus-4-1", - "opus45": "claude-opus-4-5", + "opus4": "claude-opus-4-7", "opus46": "claude-opus-4-6", "opus47": "claude-opus-4-7", - "opus3": "claude-3-opus-latest", "deepseekv3": "deepseek-chat", "deepseek3": "deepseek-chat", - "deepseek": "deepseek-chat", "gemini": "gemini-3.1-pro-preview", "gemini2": "gemini-2.0-flash", - "gemini25": "gemini-2.5-flash-preview-09-2025", + "gemini25": "gemini-2.5-flash", "gemini25pro": "gemini-2.5-pro", "gemini3": "gemini-3-pro-preview", "gemini3.1": "gemini-3.1-pro-preview", + "gemini3.1flashlite": "gemini-3.1-flash-lite-preview", "gemini3flash": "gemini-3-flash-preview", + "grok": "xai.grok-4.3", + "grok4": "xai.grok-4.3", "grok-4-fast": "xai.grok-4-fast-non-reasoning", "grok-4-fast-reasoning": "xai.grok-4-fast-reasoning", - "minimax": "hf.MiniMaxAI/MiniMax-M2.5:novita", + "minimax": "hf.MiniMaxAI/MiniMax-M2.7:fireworks-ai?temperature=1.0&top_p=0.95&top_k=40", "minimax25": "hf.MiniMaxAI/MiniMax-M2.5:fireworks-ai?temperature=1.0&top_p=0.95&top_k=40", + "minimax27": "hf.MiniMaxAI/MiniMax-M2.7:fireworks-ai?temperature=1.0&top_p=0.95&top_k=40", "minimax2.5": "hf.MiniMaxAI/MiniMax-M2.5:novita?temperature=1.0&top_p=0.95&top_k=40", "minimax21": "hf.MiniMaxAI/MiniMax-M2.1:novita", "kimi": ("hf.moonshotai/Kimi-K2.6:novita?temperature=1.0&top_p=0.95&reasoning=on"), + "kimithink": "hf.moonshotai/Kimi-K2.6:novita?temperature=1.0&top_p=0.95&reasoning=on", "gpt-oss": "hf.openai/gpt-oss-120b:cerebras", "gpt-oss-20b": "hf.openai/gpt-oss-20b", "glm47": "hf.zai-org/GLM-4.7:cerebras", "glm51": "hf.zai-org/GLM-5.1:together", "glm5": "hf.zai-org/GLM-5:novita", "glm": "hf.zai-org/GLM-5.1:together", - "qwen3": "hf.Qwen/Qwen3-Next-80B-A3B-Instruct:together", - "deepseek31": "hf.deepseek-ai/DeepSeek-V3.1", - "kimithink": "hf.moonshotai/Kimi-K2-Thinking:fireworks-ai", + "deepseek": "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", "deepseek32": "hf.deepseek-ai/DeepSeek-V3.2:fireworks-ai", + "deepseek4": "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", + "deepseek4pro": "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", + "deepseekv4pro": "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", "kimi26": "hf.moonshotai/Kimi-K2.6:novita?temperature=1.0&top_p=0.95&reasoning=on", "kimi26instant": ( "hf.moonshotai/Kimi-K2.6:novita?temperature=0.6&top_p=0.95&reasoning=off" ), "kimi-2.6": "hf.moonshotai/Kimi-K2.6:novita?temperature=1.0&top_p=0.95&reasoning=on", - "kimi25": ("hf.moonshotai/Kimi-K2.5:fireworks-ai?temperature=1.0&top_p=0.95&reasoning=on"), + "kimi25": ("hf.moonshotai/Kimi-K2.5:novita?temperature=1.0&top_p=0.95&reasoning=on"), "kimi25instant": ( - "hf.moonshotai/Kimi-K2.5:fireworks-ai?temperature=0.6&top_p=0.95&reasoning=off" - ), - "kimi-2.5": ( - "hf.moonshotai/Kimi-K2.5:fireworks-ai?temperature=1.0&top_p=0.95&reasoning=on" + "hf.moonshotai/Kimi-K2.5:novita?temperature=0.6&top_p=0.95&reasoning=off" ), + "kimi-2.5": ("hf.moonshotai/Kimi-K2.5:novita?temperature=1.0&top_p=0.95&reasoning=on"), "qwen35": ( "hf.Qwen/Qwen3.5-397B-A17B:novita" "?temperature=0.6&top_p=0.95&top_k=20&min_p=0.0" @@ -916,6 +941,10 @@ def _load_provider_class(cls, provider: Provider) -> type: return HuggingFaceLLM if provider == Provider.XAI: + from fast_agent.llm.provider.openai.xai_responses import XAIResponsesLLM + + return XAIResponsesLLM + if provider == Provider.XAI_LEGACY: from fast_agent.llm.provider.openai.llm_xai import XAILLM return XAILLM @@ -955,6 +984,10 @@ def _load_provider_class(cls, provider: Provider) -> type: from fast_agent.llm.provider.openai.openresponses import OpenResponsesLLM return OpenResponsesLLM + if provider == Provider.XAI_RESPONSES: + from fast_agent.llm.provider.openai.xai_responses import XAIExplicitResponsesLLM + + return XAIExplicitResponsesLLM except Exception as e: raise ModelConfigError( diff --git a/src/fast_agent/llm/model_overlays.py b/src/fast_agent/llm/model_overlays.py index 2288c9012..5385fbc4b 100644 --- a/src/fast_agent/llm/model_overlays.py +++ b/src/fast_agent/llm/model_overlays.py @@ -9,12 +9,13 @@ from urllib.parse import urlencode import yaml -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator import fast_agent.config as config_module -from fast_agent.config import load_yaml_mapping, resolve_environment_config_file +from fast_agent.config import load_yaml_mapping from fast_agent.core.exceptions import ModelConfigError from fast_agent.core.logging.logger import get_logger +from fast_agent.home import resolve_fast_agent_home from fast_agent.llm.model_database import ModelDatabase, ModelParameters from fast_agent.llm.provider_types import Provider @@ -143,11 +144,21 @@ class ModelOverlayMetadata(BaseModel): context_window: int | None = None max_output_tokens: int | None = None tokenizes: list[str] | None = None + json_mode: Literal["schema", "object"] | None = None + structured_tool_policy: Literal["always", "defer", "no_tools"] | None = None + model_specific: str | None = None # Legacy fallback retained for older overlay files. New overlays should use # defaults.temperature instead. default_temperature: float | None = None fast: bool | None = None + @field_validator("json_mode", mode="before") + @classmethod + def _normalize_json_mode(cls, value: object) -> object: + if isinstance(value, str) and value.strip().lower() == "none": + return None + return value + class ModelOverlayPicker(BaseModel): """Picker presentation metadata for a local overlay.""" @@ -320,6 +331,14 @@ def build_model_parameters(self) -> ModelParameters | None: } if self.manifest.metadata.tokenizes is not None: update_payload["tokenizes"] = self.manifest.metadata.tokenizes + if "json_mode" in self.manifest.metadata.model_fields_set: + update_payload["json_mode"] = self.manifest.metadata.json_mode + if self.manifest.metadata.structured_tool_policy is not None: + update_payload["structured_tool_policy"] = ( + self.manifest.metadata.structured_tool_policy + ) + if self.manifest.metadata.model_specific is not None: + update_payload["model_specific"] = self.manifest.metadata.model_specific if default_temperature is not None: update_payload["default_temperature"] = default_temperature if self.manifest.metadata.fast is not None: @@ -327,10 +346,16 @@ def build_model_parameters(self) -> ModelParameters | None: return existing.model_copy(update=update_payload) tokenizes = self.manifest.metadata.tokenizes or list(ModelDatabase.TEXT_ONLY) + json_mode: str | None = "schema" + if "json_mode" in self.manifest.metadata.model_fields_set: + json_mode = self.manifest.metadata.json_mode return ModelParameters( context_window=context_window, max_output_tokens=max_output_tokens, tokenizes=tokenizes, + json_mode=json_mode, + structured_tool_policy=self.manifest.metadata.structured_tool_policy, + model_specific=self.manifest.metadata.model_specific, default_provider=self.provider, default_temperature=default_temperature, fast=bool(self.manifest.metadata.fast), @@ -448,14 +473,13 @@ def resolve_model_overlay_paths( base_path = (start_path or Path.cwd()).resolve() override = env_dir - if override is None: - override = os.getenv("ENVIRONMENT_DIR") if override is None: configured = _settings_environment_override(start_path=start_path) if configured is not None: base_path, override = configured - env_root = resolve_environment_config_file(base_path, env_dir=override).parent + home = resolve_fast_agent_home(cwd=base_path, cli_override=override) + env_root = home.path if home is not None else base_path return ModelOverlayPaths( env_root=env_root, overlays_dir=env_root / "model-overlays", @@ -481,6 +505,114 @@ def serialize_model_overlay_manifest(manifest: ModelOverlayManifest) -> str: return f"{yaml.safe_dump(payload, sort_keys=False).rstrip()}\n" +def _split_provider_prefix(model_name: str) -> tuple[Provider | None, str]: + """Split an optional provider prefix from a model string. + + Accepts forms like "openrouter.gpt-4o", "anthropic/claude-4-sonnet-20250514", + "hf.openai/gpt-oss-120b:cerebras", or bare namespaced HF IDs like "openai/gpt-oss-120b". + + Hugging Face namespaced models (those containing "/") are special: the namespace + may collide with another provider name (e.g. "openai/...", "meta-llama/..."). + In those cases we must not strip the first segment as a provider prefix. + """ + raw = model_name.strip() + if not raw: + return None, raw + + # Explicit dot-prefixed providers must win before slash handling so specs + # like "openrouter.moonshotai/kimi-k2" do not look like bare HF repo IDs. + if "." in raw: + head, tail = raw.split(".", 1) + if head.lower() == "huggingface": + return Provider.HUGGINGFACE, tail + try: + return Provider(head.lower()), tail + except ValueError: + pass + + # HF-style namespaced models contain a slash in the model part. + # The namespace may collide with a provider name, so a remaining slash here + # is treated as part of the model ID rather than a provider separator. + if "/" in raw: + return None, raw + + return None, raw + + +def build_model_overlay_manifest_from_database( + model_name: str, + *, + provider: Provider | None = None, + overlay_name: str | None = None, + description: str | None = None, +) -> ModelOverlayManifest: + """Create a ModelOverlayManifest seeded from a ModelDatabase entry. + + This allows users to export a known-good catalog model (with its model_specific + prompt text, modalities, context window, etc.) into a local overlay that they + can then customize. + + If model_name contains an explicit provider prefix (e.g. "openrouter.gpt-4o"), + that prefix is used unless an explicit provider override is supplied. + """ + prefix_provider, bare_model = _split_provider_prefix(model_name) + + lookup_name = model_name if prefix_provider is not None else bare_model + if prefix_provider == Provider.HUGGINGFACE and model_name.lower().startswith("huggingface."): + lookup_name = f"{Provider.HUGGINGFACE.value}.{bare_model}" + effective_provider = provider or prefix_provider + if effective_provider is None and "/" in bare_model: + effective_provider = Provider.HUGGINGFACE + + existing = None + if effective_provider is not None: + existing = _existing_model_params(effective_provider, lookup_name) + if existing is None: + existing = ModelDatabase.get_model_params(lookup_name) + + if existing is None: + raise ModelConfigError( + f"Model '{model_name}' was not found in the model database", + "Check the model name or use a fully-qualified provider.model string.", + ) + + # Final provider decision: explicit arg > parsed prefix > catalog default > OPENAI + resolved_provider = effective_provider or existing.default_provider or Provider.OPENAI + + # The manifest should store the bare model name (without our own prefix) + manifest_model = bare_model + name = overlay_name or _safe_overlay_filename(manifest_model) + + # Map ModelParameters fields that are supported by ModelOverlayMetadata + json_mode: Literal["schema", "object"] | None = None + if existing.json_mode in ("schema", "object"): + json_mode = existing.json_mode # type: ignore[assignment] + + metadata = ModelOverlayMetadata( + context_window=existing.context_window, + max_output_tokens=existing.max_output_tokens, + tokenizes=existing.tokenizes if existing.tokenizes else None, + model_specific=existing.model_specific, + json_mode=json_mode, + structured_tool_policy=existing.structured_tool_policy, + fast=existing.fast, + default_temperature=existing.default_temperature, + ) + + return ModelOverlayManifest( + name=name, + provider=resolved_provider, + model=manifest_model, + metadata=metadata, + picker=ModelOverlayPicker( + label=name, + description=description or "Exported from model database", + current=True, + featured=False, + ), + ) + + def write_model_overlay_manifest( manifest: ModelOverlayManifest, *, diff --git a/src/fast_agent/llm/model_reference_config.py b/src/fast_agent/llm/model_reference_config.py index 658dbe1d1..1e1efd3ea 100644 --- a/src/fast_agent/llm/model_reference_config.py +++ b/src/fast_agent/llm/model_reference_config.py @@ -14,15 +14,17 @@ from fast_agent.config import ( Settings, deep_merge, - find_fastagent_config_files, - find_project_config_file, load_layered_model_settings, load_yaml_mapping, - resolve_config_search_root, - resolve_environment_config_file, ) from fast_agent.core.exceptions import ModelConfigError from fast_agent.core.model_resolution import parse_model_reference_token +from fast_agent.home import ( + PREFERRED_CONFIG_FILENAME, + discover_config_files, + find_config_in_directory, + resolve_fast_agent_home, +) ModelReferenceWriteTarget = Literal["env", "project"] @@ -68,7 +70,7 @@ def __init__( project_write_path: Path | None = None, ) -> None: self._start_path = (start_path or Path.cwd()).resolve() - self._env_dir = env_dir if env_dir is not None else os.getenv("ENVIRONMENT_DIR") + self._env_dir = env_dir self.paths = _discover_paths( start_path=self._start_path, env_dir=self._env_dir, @@ -227,16 +229,16 @@ def _discover_paths( resolved_project_write_path = ( project_write_path.expanduser().resolve() if project_write_path is not None else None ) - project_read_path = find_project_config_file(start_path) + project_read_path = find_config_in_directory(start_path) if resolved_project_write_path is not None and resolved_project_write_path.exists(): project_read_path = resolved_project_write_path resolved_project_path = resolved_project_write_path or project_read_path or ( - start_path / "fastagent.config.yaml" + start_path / PREFERRED_CONFIG_FILENAME ) - env_path = resolve_environment_config_file(start_path, env_dir=env_dir) - - search_root = resolve_config_search_root(start_path, env_dir=env_dir) - _, secrets_path = find_fastagent_config_files(search_root) + home = resolve_fast_agent_home(cwd=start_path, cli_override=env_dir) + env_root = home.path if home is not None else start_path + env_path = find_config_in_directory(env_root) or (env_root / PREFERRED_CONFIG_FILENAME) + secrets_path = discover_config_files(cwd=start_path, home=home).secrets_path return ModelReferenceConfigPaths( project_read_path=project_read_path, diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index 007f46d8d..7ff0ba973 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -78,18 +78,20 @@ class ModelSelectionCatalog: CatalogModelEntry(alias="gpt-4.1-nano", model="openai.gpt-4.1-nano", fast=True), ), Provider.ANTHROPIC: ( + CatalogModelEntry(alias="opus", model="claude-opus-4-7"), + CatalogModelEntry(alias="opus46", model="claude-opus-4-6"), CatalogModelEntry(alias="sonnet", model="claude-sonnet-4-6"), CatalogModelEntry(alias="haiku", model="claude-haiku-4-5", fast=True), - CatalogModelEntry(alias="opus", model="claude-opus-4-7"), ), Provider.ANTHROPIC_VERTEX: ( + CatalogModelEntry(alias="opus", model="anthropic-vertex.claude-opus-4-7"), + CatalogModelEntry(alias="opus46", model="anthropic-vertex.claude-opus-4-6"), CatalogModelEntry(alias="sonnet", model="anthropic-vertex.claude-sonnet-4-6"), CatalogModelEntry( alias="haiku", model="anthropic-vertex.claude-haiku-4-5", fast=True, ), - CatalogModelEntry(alias="opus", model="anthropic-vertex.claude-opus-4-7"), ), Provider.GOOGLE: ( CatalogModelEntry( @@ -99,16 +101,21 @@ class ModelSelectionCatalog: ), CatalogModelEntry(alias="gemini3", model="google.gemini-3-pro-preview"), CatalogModelEntry(alias="gemini3.1", model="google.gemini-3.1-pro-preview"), + CatalogModelEntry( + alias="gemini3.1flashlite", + model="google.gemini-3.1-flash-lite-preview", + fast=True, + ), ), Provider.XAI: ( + CatalogModelEntry(alias="grok", model="xai.grok-4.3"), CatalogModelEntry(alias="grok41fast", model="grok-4-1-fast-reasoning", fast=True), CatalogModelEntry( alias="grok41fast-nr", model="grok-4-1-fast-non-reasoning", fast=True ), - CatalogModelEntry(alias="grok4", model="xai.grok-4"), ), Provider.DEEPSEEK: ( - CatalogModelEntry(alias="deepseek", model="deepseek.deepseek-chat", fast=True), + CatalogModelEntry(alias="deepseek3", model="deepseek.deepseek-chat", fast=True), ), Provider.OPENROUTER: (), Provider.ALIYUN: ( @@ -116,6 +123,11 @@ class ModelSelectionCatalog: CatalogModelEntry(alias="qwen3-max", model="aliyun.qwen3-max"), ), Provider.HUGGINGFACE: ( + CatalogModelEntry( + alias="deepseek4", + model="hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", + current=False, + ), CatalogModelEntry( alias="kimi26", display_label="Kimi 2.6", @@ -133,6 +145,11 @@ class ModelSelectionCatalog: CatalogModelEntry( alias="glm51", display_label="GLM 5.1", model="hf.zai-org/GLM-5.1:together" ), + CatalogModelEntry( + alias="minimax27", + display_label="Minimax 2.7", + model="hf.MiniMaxAI/MiniMax-M2.7:fireworks-ai?temperature=1.0&top_p=0.95&top_k=40", + ), CatalogModelEntry( alias="qwen35", display_label="Qwen 3.5-397B-A17B", @@ -155,22 +172,19 @@ class ModelSelectionCatalog: alias="minimax25", display_label="Minimax 2.5", model="hf.MiniMaxAI/MiniMax-M2.5:fireworks-ai?temperature=1.0&top_p=0.95&top_k=40", + current=False, ), CatalogModelEntry( alias="kimi25", display_label="Kimi 2.5", - model=( - "hf.moonshotai/Kimi-K2.5:fireworks-ai?temperature=1.0&top_p=0.95&reasoning=on" - ), + model=("hf.moonshotai/Kimi-K2.5:novita?temperature=1.0&top_p=0.95&reasoning=on"), fast=True, current=False, ), CatalogModelEntry( alias="kimi25instant", display_label="Kimi 2.5 (instant)", - model=( - "hf.moonshotai/Kimi-K2.5:fireworks-ai?temperature=0.6&top_p=0.95&reasoning=off" - ), + model=("hf.moonshotai/Kimi-K2.5:novita?temperature=0.6&top_p=0.95&reasoning=off"), fast=True, current=False, ), @@ -190,9 +204,7 @@ class ModelSelectionCatalog: CatalogModelEntry( alias="deepseek32", model="hf.deepseek-ai/DeepSeek-V3.2:fireworks-ai", - ), - CatalogModelEntry( - alias="kimi-k2-thinking", model="hf.moonshotai/Kimi-K2-Thinking:together" + current=False, ), ), Provider.CODEX_RESPONSES: ( @@ -218,10 +230,6 @@ class ModelSelectionCatalog: model="codexresponses.gpt-5.4-mini?reasoning=medium", fast=True, ), - CatalogModelEntry( - alias="codexplan52", - model="codexresponses.gpt-5.2-codex?reasoning=high", - ), ), Provider.GROQ: ( CatalogModelEntry( diff --git a/src/fast_agent/llm/provider/anthropic/llm_anthropic.py b/src/fast_agent/llm/provider/anthropic/llm_anthropic.py index 3ad11e664..ea98c83dc 100644 --- a/src/fast_agent/llm/provider/anthropic/llm_anthropic.py +++ b/src/fast_agent/llm/provider/anthropic/llm_anthropic.py @@ -33,6 +33,7 @@ ) from opentelemetry.semconv_ai import LLMRequestTypeValues, SpanAttributes from opentelemetry.trace import Span, Status, StatusCode +from pydantic import BaseModel from fast_agent.constants import ( ANTHROPIC_ASSISTANT_RAW_CONTENT, @@ -383,6 +384,11 @@ def _save_stream_chunk(filename_base: Path | None, chunk: Any) -> None: logger.debug(f"Failed to save stream chunk: {e}") +def _transform_anthropic_schema(schema: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: + """Return an Anthropic-compatible schema using the SDK's schema transformer.""" + return transform_schema(schema) + + def _ensure_additional_properties_false(schema: dict[str, Any]) -> dict[str, Any]: """Ensure object schemas use Anthropic-compatible additionalProperties=false.""" result = deepcopy(schema) @@ -898,6 +904,25 @@ def _resolve_structured_output_mode( return "tool_use" return "tool_use" + def _resolve_structured_tool_policy( + self, + request_params: RequestParams, + ) -> Literal["always", "defer", "no_tools"]: + if request_params.structured_tool_policy != "auto": + return request_params.structured_tool_policy + + model_name = request_params.model or self.default_request_params.model or self._model_name + if model_name: + structured_mode = self._resolve_structured_output_mode( + model_name, + None, + request_params.structured_schema, + ) + if structured_mode == "tool_use": + return "no_tools" + + return super()._resolve_structured_tool_policy(request_params) + def _is_auto_tool_use_structured_fallback( self, model: str, @@ -922,14 +947,11 @@ def _build_output_format( structured_schema: dict[str, Any] | None = None, ) -> dict[str, Any]: if structured_schema is not None: - schema = structured_schema + schema = _transform_anthropic_schema(structured_schema) elif structured_model is not None: - try: - schema = transform_schema(structured_model) - except Exception: - schema = structured_model.model_json_schema() + schema = _transform_anthropic_schema(cast("type[BaseModel]", structured_model)) else: - schema = {"type": "object"} + schema = _transform_anthropic_schema({"type": "object"}) schema = _ensure_additional_properties_false(schema) return {"type": "json_schema", "schema": schema} @@ -962,17 +984,14 @@ async def _prepare_tools( ) schema: dict[str, object] if structured_schema is not None: - schema = cast( - "dict[str, object]", - _ensure_additional_properties_false(structured_schema), - ) + schema = cast("dict[str, object]", _transform_anthropic_schema(structured_schema)) elif structured_model is not None: schema = cast( "dict[str, object]", - _ensure_additional_properties_false(structured_model.model_json_schema()), + _transform_anthropic_schema(cast("type[BaseModel]", structured_model)), ) else: - schema = {"type": "object"} + schema = cast("dict[str, object]", _transform_anthropic_schema({"type": "object"})) return [ ToolParam( name=STRUCTURED_OUTPUT_TOOL_NAME, @@ -2293,9 +2312,7 @@ def _prepare_structured_request( request_params: RequestParams, tools: list[Tool] | None = None, ) -> tuple[list[PromptMessageExtended], RequestParams]: - if not request_params.structured_schema or not tools: - return messages, request_params - if any(message.tool_results for message in messages): + if not self._should_suppress_structured_schema_for_tools(messages, request_params, tools): return messages, request_params return messages, request_params.model_copy(update={"structured_schema": None}) diff --git a/src/fast_agent/llm/provider/bedrock/llm_bedrock.py b/src/fast_agent/llm/provider/bedrock/llm_bedrock.py index 49b343690..53eba2209 100644 --- a/src/fast_agent/llm/provider/bedrock/llm_bedrock.py +++ b/src/fast_agent/llm/provider/bedrock/llm_bedrock.py @@ -2127,6 +2127,16 @@ async def _apply_prompt_provider_specific( # For assistant messages: Return the last message (no completion needed) return last_message + effective_params = self.get_request_params(request_params) + if effective_params.structured_schema: + _, response = await self._apply_prompt_provider_specific_structured_schema( + multipart_messages, + effective_params.structured_schema, + effective_params, + tools, + ) + return response + # Convert the last user message to Bedrock message parameter format message_param = BedrockConverter.convert_to_bedrock(last_message) @@ -2135,7 +2145,7 @@ async def _apply_prompt_provider_specific( # via _convert_to_provider_format() return await self._bedrock_completion( message_param, - request_params, + effective_params, tools, pre_messages=None, history=multipart_messages, @@ -2224,7 +2234,9 @@ async def _apply_prompt_provider_specific_structured( # Fall through to normal generation path pass - request_params = self.get_request_params(request_params) + request_params = self.get_request_params(request_params).model_copy( + update={"structured_schema": None} + ) # For structured outputs: disable reasoning entirely and set temperature=0 for deterministic JSON # This avoids conflicts between reasoning (requires temperature=1) and structured output (wants temperature=0) @@ -2265,8 +2277,9 @@ async def _apply_prompt_provider_specific_structured( ] # IMPORTANT: Do NOT mutate the caller's messages. Create a deep copy of the last - # user message, append the schema to the copy only, and pass just that copy into - # the provider-specific path. This prevents contamination of routed messages. + # user message, append the schema to the copy only, and pass the full conversation + # with that copied last turn into the provider-specific path. This preserves tool + # and history context while preventing contamination of routed messages. try: temp_last = multipart_messages[-1].model_copy(deep=True) except Exception: @@ -2282,8 +2295,9 @@ async def _apply_prompt_provider_specific_structured( ) try: + structured_messages = [*multipart_messages[:-1], temp_last] result: PromptMessageExtended = await self._apply_prompt_provider_specific( - [temp_last], request_params + structured_messages, request_params ) try: parsed_model, _ = self._structured_from_multipart(result, model) @@ -2314,8 +2328,9 @@ async def _apply_prompt_provider_specific_structured( ) temp_last_retry.add_text("\n".join(strict_parts + [simplified_schema_text])) + retry_messages = [*multipart_messages[:-1], temp_last_retry] retry_result: PromptMessageExtended = await self._apply_prompt_provider_specific( - [temp_last_retry], request_params + retry_messages, request_params ) return self._structured_from_multipart(retry_result, model) finally: @@ -2327,6 +2342,7 @@ async def _apply_prompt_provider_specific_structured_schema( multipart_messages: list[PromptMessageExtended], schema: dict[str, Any], request_params: RequestParams | None = None, + tools: list[Tool] | None = None, ) -> tuple[Any | None, PromptMessageExtended]: try: if multipart_messages and multipart_messages[-1].role == "assistant": @@ -2339,7 +2355,9 @@ async def _apply_prompt_provider_specific_structured_schema( except Exception: pass - request_params = self.get_request_params(request_params) + request_params = self.get_request_params(request_params).model_copy( + update={"structured_schema": None} + ) original_reasoning_effort = self.reasoning_effort self.set_reasoning_effort(ReasoningEffortSetting(kind="toggle", value=False)) @@ -2385,7 +2403,14 @@ async def _apply_prompt_provider_specific_structured_schema( temp_last.add_text("\n".join(prompt_parts)) try: - result = await self._apply_prompt_provider_specific([temp_last], request_params) + structured_messages = [*multipart_messages[:-1], temp_last] + result = await self._apply_prompt_provider_specific( + structured_messages, + request_params, + tools, + ) + if result.tool_calls: + return None, result parsed, _ = self._structured_schema_from_multipart(result, schema) if parsed is None: raise ValueError("structured parse returned None; triggering retry") @@ -2409,14 +2434,28 @@ async def _apply_prompt_provider_specific_structured_schema( ) temp_last_retry.add_text("\n".join(strict_parts)) + retry_messages = [*multipart_messages[:-1], temp_last_retry] retry_result = await self._apply_prompt_provider_specific( - [temp_last_retry], + retry_messages, request_params, + tools, ) + if retry_result.tool_calls: + return None, retry_result return self._structured_schema_from_multipart(retry_result, schema) finally: self.set_reasoning_effort(original_reasoning_effort) + def _prepare_structured_request( + self, + messages: list[PromptMessageExtended], + request_params: RequestParams, + tools: list[Tool] | None = None, + ) -> tuple[list[PromptMessageExtended], RequestParams]: + if not self._should_defer_structured_schema_for_tools(messages, request_params, tools): + return messages, request_params + return messages, request_params.model_copy(update={"structured_schema": None}) + def _clean_json_response(self, text: str) -> str: """Clean up JSON response by removing text before first { and after last }. diff --git a/src/fast_agent/llm/provider/google/llm_google_native.py b/src/fast_agent/llm/provider/google/llm_google_native.py index 6d3c1d118..f48626c80 100644 --- a/src/fast_agent/llm/provider/google/llm_google_native.py +++ b/src/fast_agent/llm/provider/google/llm_google_native.py @@ -181,13 +181,12 @@ def _vertex_cfg(self) -> tuple[bool, str | None, str | None]: ) def _resolve_model_name(self, model: str) -> str: - """Resolve model name; for Vertex, apply a generic preview→base fallback. + """Resolve model name; for Vertex, expand first-party short ids. * If the caller passes a full publisher resource name, it is respected as-is. * If Vertex is not enabled, the short id is returned unchanged (Developer API path). * If Vertex is enabled, short first-party Google model ids are expanded under - `publishers/google`, applying a preview→base fallback so that e.g. - 'gemini-2.5-flash-preview-09-2025' becomes 'gemini-2.5-flash'. + `publishers/google`. * Known partner model ids such as Anthropic Claude are left untouched so Vertex can resolve them using the provider-native short model name from the docs. """ @@ -204,10 +203,7 @@ def _resolve_model_name(self, model: str) -> str: if normalized.startswith(_GOOGLE_VERTEX_PARTNER_MODEL_PREFIXES): return model - # Vertex path: strip any '-preview-…' suffix to fall back to the base model id. - base_model = model.split("-preview-", 1)[0] if "-preview-" in model else model - - return f"projects/{project_id}/locations/{location}/publishers/google/models/{base_model}" + return f"projects/{project_id}/locations/{location}/publishers/google/models/{model}" def _initialize_google_client(self) -> genai.Client: """ @@ -555,6 +551,7 @@ async def _google_completion( *, response_mime_type: str | None = None, response_schema: object | None = None, + suppress_tools: bool | None = None, ) -> PromptMessageExtended: """ Process a query using Google's generate_content API and available tools. @@ -573,8 +570,16 @@ async def _google_completion( self.logger.debug(f"Google completion requested with messages: {conversation_history}") self._log_chat_progress(self.chat_turn(), model=request_params.model) + if suppress_tools is None: + suppress_tools = ( + self._has_structured_intent(request_params) + and bool(tools) + and self._resolve_structured_tool_policy(request_params) == "no_tools" + ) available_tools: list[types.Tool] = ( - self._converter.convert_to_google_tools(tools or []) if tools else [] + self._converter.convert_to_google_tools(tools or []) + if tools and not suppress_tools + else [] ) # 2. Prepare generate_content arguments @@ -585,15 +590,14 @@ async def _google_completion( thinking_level=thinking_level, ) - # Apply structured output config OR tool calling (mutually exclusive) + # Apply structured output and tool calling. Google native supports combining + # response_schema with tools, but no_tools/defer final turns suppress tools. if response_schema or response_mime_type: - # Structured output mode: disable tool use if response_mime_type: generate_content_config.response_mime_type = response_mime_type if response_schema is not None: generate_content_config.response_schema = response_schema - elif available_tools: - # Tool calling enabled only when not doing structured output + if available_tools: generate_content_config.tools = available_tools # type: ignore[assignment] generate_content_config.tool_config = types.ToolConfig( function_calling_config=types.FunctionCallingConfig( @@ -679,10 +683,6 @@ async def _google_completion( part ) # Collect text for potential assistant message display elif isinstance(part, CallToolRequestParams): - # This is a function call requested by the model - # If in structured mode, ignore tool calls per either-or rule - if response_schema or response_mime_type: - continue tool_calls_to_execute.append(part) # Collect tool calls to execute if not responses and (response_schema or response_mime_type): @@ -742,9 +742,7 @@ def _prepare_structured_request( request_params: RequestParams, tools: list[McpTool] | None = None, ) -> tuple[list[PromptMessageExtended], RequestParams]: - if not request_params.structured_schema or not tools: - return messages, request_params - if any(message.tool_results for message in messages): + if not self._should_defer_structured_schema_for_tools(messages, request_params, tools): return messages, request_params return messages, request_params.model_copy(update={"structured_schema": None}) @@ -815,6 +813,9 @@ async def _apply_prompt_provider_specific( conversation_history, request_params=request_params, tools=tools, + suppress_tools=self._should_suppress_tools_for_structured_final( + multipart_messages, request_params, tools + ), ) def _convert_extended_messages_to_provider( diff --git a/src/fast_agent/llm/provider/openai/llm_openai.py b/src/fast_agent/llm/provider/openai/llm_openai.py index b62c5c303..90e5e3c4e 100644 --- a/src/fast_agent/llm/provider/openai/llm_openai.py +++ b/src/fast_agent/llm/provider/openai/llm_openai.py @@ -43,6 +43,7 @@ from fast_agent.llm.provider.openai.multipart_converter_openai import OpenAIConverter from fast_agent.llm.provider.openai.responses_files import ResponsesFileMixin from fast_agent.llm.provider.openai.schema_sanitizer import ( + sanitize_response_format_schema, sanitize_tool_input_schema, should_strip_tool_schema_defaults, ) @@ -1187,9 +1188,10 @@ def _prepare_structured_request( request_params: RequestParams, tools: list[Tool] | None = None, ) -> tuple[list[PromptMessageExtended], RequestParams]: - del tools if not request_params.structured_schema or request_params.response_format: return messages, request_params + if self._should_defer_structured_schema_for_tools(messages, request_params, tools): + return messages, request_params.model_copy(update={"structured_schema": None}) return messages, request_params.model_copy( update={ "response_format": self.schema_to_response_format( @@ -1213,6 +1215,19 @@ async def _apply_prompt_provider_specific_structured_schema( request_params, ) + def schema_to_response_format( + self, + schema: dict[str, Any], + *, + name: str = "structured_output", + strict: bool = True, + ) -> dict[str, Any]: + return FastAgentLLM.schema_to_response_format( + sanitize_response_format_schema(schema) if strict else schema, + name=name, + strict=strict, + ) + def _prepare_api_request( self, messages: list[ChatCompletionMessageParam], diff --git a/src/fast_agent/llm/provider/openai/llm_openai_compatible.py b/src/fast_agent/llm/provider/openai/llm_openai_compatible.py index 625bb4993..5f446b88c 100644 --- a/src/fast_agent/llm/provider/openai/llm_openai_compatible.py +++ b/src/fast_agent/llm/provider/openai/llm_openai_compatible.py @@ -4,6 +4,7 @@ from fast_agent.interfaces import ModelT from fast_agent.llm.fastagent_llm import FastAgentLLM +from fast_agent.llm.model_database import ModelDatabase from fast_agent.llm.provider.openai.llm_openai import OpenAILLM from fast_agent.mcp.helpers.content_helpers import split_thinking_content from fast_agent.types import PromptMessageExtended, RequestParams @@ -30,14 +31,24 @@ def _prepare_structured_request( ) -> tuple[list[PromptMessageExtended], RequestParams]: if not request_params.structured_schema: return messages, request_params + if self._should_defer_structured_schema_for_tools(messages, request_params, tools): + return messages, request_params.model_copy(update={"structured_schema": None}) + + prepared_params = request_params + json_mode = self._structured_json_mode(request_params) + if json_mode == "schema" and not request_params.response_format: + return messages, request_params.model_copy( + update={ + "response_format": self.schema_to_response_format( + request_params.structured_schema + ) + } + ) + if not self._supports_structured_prompt(): return messages, request_params - if tools and not any(message.tool_results for message in messages): - return messages, request_params - prepared_params = request_params - prompt_format = self._structured_prompt_format() - if prompt_format == "json_object" and not request_params.response_format: + if json_mode == "object" and not request_params.response_format: prepared_params = request_params.model_copy( update={"response_format": {"type": "json_object"}} ) @@ -77,14 +88,26 @@ async def _apply_prompt_provider_specific_structured( request_params, ) - prompt_format = self._structured_prompt_format() - if prompt_format == "json_object" and not request_params.response_format: + json_mode = self._structured_json_mode(request_params) + if json_mode == "schema" and not request_params.response_format: + schema = self.model_to_response_format(model) + if schema: + request_params.response_format = schema + return await super()._apply_prompt_provider_specific_structured( + multipart_messages, model, request_params + ) + + if json_mode == "object" and not request_params.response_format: request_params.response_format = {"type": "json_object"} instructions = self._build_structured_prompt_instruction(model) if instructions: multipart_messages[-1].add_text(instructions) + if json_mode is None: + result = await self._apply_prompt_provider_specific(multipart_messages, request_params) + return self._structured_from_multipart(result, model) + return await super()._apply_prompt_provider_specific_structured( multipart_messages, model, request_params ) @@ -112,8 +135,17 @@ async def _apply_prompt_provider_specific_structured_schema( request_params, ) - prompt_format = self._structured_prompt_format() - if prompt_format == "json_object" and not request_params.response_format: + json_mode = self._structured_json_mode(request_params) + if json_mode == "schema" and not request_params.response_format: + request_params.response_format = self.schema_to_response_format(schema) + return await FastAgentLLM._apply_prompt_provider_specific_structured_schema( + self, + multipart_messages, + schema, + request_params, + ) + + if json_mode == "object" and not request_params.response_format: request_params.response_format = {"type": "json_object"} instructions = self._build_structured_prompt_instruction_from_schema(schema) @@ -135,6 +167,24 @@ def _structured_prompt_format(self) -> str | None: """Return the response_format type this provider expects.""" return "json_object" + def _structured_json_mode(self, request_params: RequestParams | None = None) -> str | None: + model_name = ( + request_params.model + if request_params and request_params.model + else self.default_request_params.model + if self.default_request_params + else self._model_name + ) + if not model_name: + return self._structured_prompt_format() + try: + params = self._get_model_params(model_name) + except Exception: + params = ModelDatabase.get_model_params(model_name) + if params is not None: + return params.json_mode + return self._structured_prompt_format() + def _build_structured_prompt_instruction(self, model: Type[ModelT]) -> str | None: return self._build_structured_prompt_instruction_from_schema(model.model_json_schema()) diff --git a/src/fast_agent/llm/provider/openai/llm_xai.py b/src/fast_agent/llm/provider/openai/llm_xai.py index 269b491dd..225e53283 100644 --- a/src/fast_agent/llm/provider/openai/llm_xai.py +++ b/src/fast_agent/llm/provider/openai/llm_xai.py @@ -9,9 +9,11 @@ class XAILLM(OpenAILLM): - def __init__(self, **kwargs) -> None: + config_section: str | None = "xai" + + def __init__(self, provider: Provider = Provider.XAI_LEGACY, **kwargs) -> None: kwargs.pop("provider", None) - super().__init__(provider=Provider.XAI, **kwargs) + super().__init__(provider=provider, **kwargs) def _initialize_default_params(self, kwargs: dict) -> RequestParams: """Initialize xAI parameters""" diff --git a/src/fast_agent/llm/provider/openai/responses.py b/src/fast_agent/llm/provider/openai/responses.py index 4bd1908aa..79f75d9db 100644 --- a/src/fast_agent/llm/provider/openai/responses.py +++ b/src/fast_agent/llm/provider/openai/responses.py @@ -44,6 +44,7 @@ send_response_request, ) from fast_agent.llm.provider.openai.schema_sanitizer import ( + sanitize_response_format_schema, sanitize_tool_input_schema, should_strip_tool_schema_defaults, ) @@ -663,9 +664,10 @@ def _prepare_structured_request( request_params: RequestParams, tools: list[Tool] | None = None, ) -> tuple[list[PromptMessageExtended], RequestParams]: - del tools if not request_params.structured_schema or request_params.response_format: return messages, request_params + if self._should_defer_structured_schema_for_tools(messages, request_params, tools): + return messages, request_params.model_copy(update={"structured_schema": None}) return messages, request_params.model_copy( update={ "response_format": self.schema_to_response_format( @@ -689,6 +691,19 @@ async def _apply_prompt_provider_specific_structured_schema( request_params, ) + def schema_to_response_format( + self, + schema: dict[str, Any], + *, + name: str = "structured_output", + strict: bool = True, + ) -> dict[str, Any]: + return FastAgentLLM.schema_to_response_format( + sanitize_response_format_schema(schema) if strict else schema, + name=name, + strict=strict, + ) + def _build_response_args( self, input_items: list[dict[str, Any]], diff --git a/src/fast_agent/llm/provider/openai/responses_output.py b/src/fast_agent/llm/provider/openai/responses_output.py index dc7085f65..af50dc30c 100644 --- a/src/fast_agent/llm/provider/openai/responses_output.py +++ b/src/fast_agent/llm/provider/openai/responses_output.py @@ -180,14 +180,17 @@ def _extract_raw_assistant_message_items( self._print_phase_message(output_item, serialized_item) serialized_items.append(serialized_item) - if not phases: + if not serialized_items: return [], None - unique_phases = set(phases) - message_phase = phases[0] if len(unique_phases) == 1 else None blocks: list[ContentBlock] = [ TextContent(type="text", text=json.dumps(payload)) for payload in serialized_items ] + if not phases or len(phases) != len(serialized_items): + return blocks, None + + unique_phases = set(phases) + message_phase = phases[0] if len(unique_phases) == 1 else None return blocks, message_phase def _record_usage(self, usage: Any, model_name: str) -> None: diff --git a/src/fast_agent/llm/provider/openai/schema_sanitizer.py b/src/fast_agent/llm/provider/openai/schema_sanitizer.py index 4486d7b29..3a419f506 100644 --- a/src/fast_agent/llm/provider/openai/schema_sanitizer.py +++ b/src/fast_agent/llm/provider/openai/schema_sanitizer.py @@ -1,5 +1,7 @@ from typing import Any +from fast_agent.llm.structured_schema import sanitize_structured_output_schema + _STRUCTURAL_SCHEMA_KEYS = frozenset( { "$ref", @@ -70,6 +72,15 @@ def sanitize_tool_input_schema(input_schema: dict[str, Any]) -> dict[str, Any]: return {"type": "object", "properties": {}} +def sanitize_response_format_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Return an OpenAI strict-compatible response_format JSON schema.""" + return sanitize_structured_output_schema( + schema, + require_all_properties=True, + additional_properties_false=True, + ) + + def should_strip_tool_schema_defaults(model_name: str | None) -> bool: if not model_name: return False diff --git a/src/fast_agent/llm/provider/openai/xai_responses.py b/src/fast_agent/llm/provider/openai/xai_responses.py new file mode 100644 index 000000000..93f83b0d8 --- /dev/null +++ b/src/fast_agent/llm/provider/openai/xai_responses.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any + +from fast_agent.llm.provider.openai.llm_xai import XAI_BASE_URL +from fast_agent.llm.provider.openai.responses import ResponsesLLM +from fast_agent.llm.provider.openai.responses_websocket import ( + ResponsesWsRequestPlanner, + StatelessResponsesWsPlanner, +) +from fast_agent.llm.provider_types import Provider + +if TYPE_CHECKING: + from mcp import Tool + + from fast_agent.llm.provider.openai.responses import ResponsesTransport + from fast_agent.types import RequestParams + +DEFAULT_XAI_RESPONSES_MODEL = "grok-4.3" + + +class XAIResponsesLLM(ResponsesLLM): + """LLM implementation for xAI's Responses-compatible API.""" + + config_section: str | None = "xai" + + def __init__(self, provider: Provider = Provider.XAI, **kwargs: Any) -> None: + provider = kwargs.pop("provider", provider) + self.config_section = "xairesponses" if provider == Provider.XAI_RESPONSES else "xai" + super().__init__(provider=provider, **kwargs) + + def _initialize_default_params(self, kwargs: dict[str, Any]) -> RequestParams: + params = self._initialize_default_params_with_model_fallback( + kwargs, + DEFAULT_XAI_RESPONSES_MODEL, + ) + params.parallel_tool_calls = False + return params + + def _provider_config_fallback_sections(self) -> tuple[str, ...]: + return ("xai",) + + def _default_transport_setting(self) -> ResponsesTransport: + return "sse" + + @property + def web_search_supported(self) -> bool: + return False + + @property + def service_tier_supported(self) -> bool: + return False + + def _provider_base_url(self) -> str | None: + base_url: str | None = os.getenv("XAI_BASE_URL", XAI_BASE_URL) + settings = self._get_provider_config() + if settings and getattr(settings, "base_url", None): + base_url = settings.base_url + return base_url + + def _provider_default_headers(self) -> dict[str, str] | None: + settings = self._get_provider_config() + return getattr(settings, "default_headers", None) if settings else None + + def _build_websocket_headers(self) -> dict[str, str]: + headers = dict(self._default_headers() or {}) + headers.setdefault("Authorization", f"Bearer {self._api_key()}") + return headers + + def _new_ws_request_planner(self) -> ResponsesWsRequestPlanner: + # Live xAI websocket smoke tests currently hang on store=false + # `previous_response_id` continuations. Keep ZDR/store=false semantics + # by replaying full context on each websocket turn until xAI's in-memory + # continuation path behaves as documented. + return StatelessResponsesWsPlanner() + + def _build_response_args( + self, + input_items: list[dict[str, Any]], + request_params: RequestParams, + tools: list[Tool] | None, + ) -> dict[str, Any]: + args = super()._build_response_args(input_items, request_params, tools) + # Keep the first pass xAI payload conservative; these are OpenAI-specific + # Responses extensions and xAI's websocket docs show the portable core. + args.pop("include", None) + args.pop("service_tier", None) + args.pop("reasoning", None) + return args + + +class XAIExplicitResponsesLLM(XAIResponsesLLM): + """Compatibility provider for the explicit `xairesponses` provider name.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(provider=Provider.XAI_RESPONSES, **kwargs) diff --git a/src/fast_agent/llm/provider_key_manager.py b/src/fast_agent/llm/provider_key_manager.py index 1e965f6c7..43f160253 100644 --- a/src/fast_agent/llm/provider_key_manager.py +++ b/src/fast_agent/llm/provider_key_manager.py @@ -18,6 +18,8 @@ "responses": "OPENAI_API_KEY", # Temporary workaround "openresponses": "OPENRESPONSES_API_KEY", "codexresponses": "CODEX_API_KEY", + "xairesponses": "XAI_API_KEY", + "xai_legacy": "XAI_API_KEY", } PROVIDER_CONFIG_KEY_ALIASES: dict[str, tuple[str, ...]] = { # HuggingFace historically used "huggingface" (full name) in config files, @@ -27,6 +29,8 @@ # Responses shares OpenAI credentials; allow reading openai.api_key when # responses.api_key is omitted. "responses": ("openai",), + "xairesponses": ("xai",), + "xai_legacy": ("xai",), } API_KEY_HINT_TEXT = "" API_KEYLESS_PROVIDERS: frozenset[str] = frozenset({"anthropic-vertex"}) diff --git a/src/fast_agent/llm/provider_types.py b/src/fast_agent/llm/provider_types.py index 4bedb44cd..97052c466 100644 --- a/src/fast_agent/llm/provider_types.py +++ b/src/fast_agent/llm/provider_types.py @@ -35,8 +35,10 @@ def config_name(self) -> str: ALIYUN = ("aliyun", "Aliyun") # Aliyun Bailian OpenAI Service HUGGINGFACE = ("hf", "HuggingFace") # For HuggingFace MCP connections XAI = ("xai", "XAI") # For xAI Grok models + XAI_LEGACY = ("xai_legacy", "XAI Legacy") # For xAI Chat Completions BEDROCK = ("bedrock", "Bedrock") GROQ = ("groq", "Groq") CODEX_RESPONSES = ("codexresponses", "Codex Responses") RESPONSES = ("responses", "Responses") OPENRESPONSES = ("openresponses", "OpenResponses") + XAI_RESPONSES = ("xairesponses", "xAI Responses") diff --git a/src/fast_agent/llm/request_params.py b/src/fast_agent/llm/request_params.py index 6cc7e5236..096f77f90 100644 --- a/src/fast_agent/llm/request_params.py +++ b/src/fast_agent/llm/request_params.py @@ -18,6 +18,7 @@ ResponseMode: TypeAlias = Literal["inherit", "postprocess", "passthrough"] ToolResultMode: TypeAlias = Literal["postprocess", "passthrough", "selectable"] +StructuredToolPolicy: TypeAlias = Literal["auto", "always", "defer", "no_tools"] def response_mode_to_tool_result_mode(response_mode: ResponseMode) -> ToolResultMode | None: @@ -82,6 +83,23 @@ class RequestParams(CreateMessageRequestParams): Providers may translate this to response_format, tool schemas, or prompt instructions. """ + structured_tool_policy: StructuredToolPolicy = "auto" + """ + Internal policy for raw-schema structured generation when tools are available. + + Applies to ``generate(..., RequestParams(structured_schema=...), tools=...)``. + The typed ``structured(model, ...)`` API remains a final-answer path and does + not receive regular tools. + + - ``auto``: use the provider/model default. + - ``always``: apply raw-schema constraints on every turn, including the first + tool-selection turn. + - ``defer``: suppress structured-output constraints until a tool result is + present, useful for models that otherwise answer JSON instead of calling a + required tool. + - ``no_tools``: suppress regular tools and produce one structured response. + """ + template_vars: dict[str, Any] = Field(default_factory=dict) """ Optional dictionary of template variables for dynamic templates. Currently only works for TensorZero inference backend diff --git a/src/fast_agent/llm/resolved_model.py b/src/fast_agent/llm/resolved_model.py index 16ab18c4a..a46d6e92c 100644 --- a/src/fast_agent/llm/resolved_model.py +++ b/src/fast_agent/llm/resolved_model.py @@ -85,6 +85,11 @@ def json_mode(self) -> str | None: model_params = self.model_params return model_params.json_mode if model_params is not None else None + @property + def structured_tool_policy(self) -> Literal["always", "defer", "no_tools"] | None: + model_params = self.model_params + return model_params.structured_tool_policy if model_params is not None else None + @property def reasoning_mode(self) -> str | None: model_params = self.model_params @@ -190,6 +195,21 @@ def apply_request_defaults( update={"service_tier": config.service_tier} ) + if config.structured_tool_policy is not None: + has_explicit_structured_tool_policy = ( + effective_request_params is not None + and "structured_tool_policy" in effective_request_params.model_fields_set + ) + if not has_explicit_structured_tool_policy: + if effective_request_params is None: + effective_request_params = RequestParams( + structured_tool_policy=config.structured_tool_policy + ) + else: + effective_request_params = effective_request_params.model_copy( + update={"structured_tool_policy": config.structured_tool_policy} + ) + default_max_tokens = self.default_max_tokens if default_max_tokens is not None: has_explicit_max_tokens = ( @@ -303,4 +323,6 @@ def resolve_base_model_params( model_name: str, ) -> ModelParameters | None: """Resolve base model metadata without preferring overlay runtime mutations.""" + if provider == Provider.HUGGINGFACE and ":" in model_name: + model_name = model_name.rsplit(":", 1)[0] return ModelDatabase.get_model_params(model_name, provider=provider) diff --git a/src/fast_agent/llm/structured_schema.py b/src/fast_agent/llm/structured_schema.py index c04663f9e..9cab313d3 100644 --- a/src/fast_agent/llm/structured_schema.py +++ b/src/fast_agent/llm/structured_schema.py @@ -1,8 +1,17 @@ from __future__ import annotations +import json +from copy import deepcopy +from importlib import import_module +from pathlib import Path from typing import Any +from jsonschema.exceptions import SchemaError from jsonschema.validators import validator_for +from pydantic import BaseModel + +PydanticModel = type[BaseModel] +StructuredSchemaSource = dict[str, Any] | PydanticModel def validate_json_schema_definition(schema: dict[str, Any]) -> dict[str, Any]: @@ -17,3 +26,218 @@ def validate_json_instance(instance: Any, schema: dict[str, Any]) -> None: validator_class = validator_for(schema) validator = validator_class(schema) validator.validate(instance) + + +def load_json_schema_file(path: str | Path) -> dict[str, Any]: + schema_path = Path(path).expanduser() + try: + raw_text = schema_path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"Could not read JSON schema file {schema_path}: {exc}") from exc + + try: + loaded = json.loads(raw_text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON schema file {schema_path}: {exc}") from exc + + if not isinstance(loaded, dict): + raise ValueError(f"JSON schema file {schema_path} must contain a JSON object") + + try: + return validate_json_schema_definition(loaded) + except SchemaError as exc: + raise ValueError(f"Invalid JSON schema in {schema_path}: {exc.message}") from exc + + +def load_pydantic_model(spec: str) -> PydanticModel: + module_name, separator, class_path = spec.partition(":") + if not module_name or separator != ":" or not class_path: + raise ValueError("Expected --schema-model in the form module.path:ClassName") + + try: + target: object = import_module(module_name) + except ImportError as exc: + raise ValueError(f"Could not import schema model module {module_name}: {exc}") from exc + + try: + for part in class_path.split("."): + target = getattr(target, part) + except AttributeError as exc: + raise ValueError(f"Could not resolve schema model {spec}: missing {part}") from exc + + if not isinstance(target, type) or not issubclass(target, BaseModel): + raise ValueError("--schema-model must point to a pydantic BaseModel subclass") + + return target + + +def load_structured_schema_source( + *, + json_schema: str | Path | None, + schema_model: str | None, +) -> StructuredSchemaSource: + if json_schema is not None and schema_model is not None: + raise ValueError("--json-schema and --schema-model cannot be used together") + if json_schema is None and schema_model is None: + raise ValueError("One of --json-schema or --schema-model is required") + if schema_model is not None: + return load_pydantic_model(schema_model) + assert json_schema is not None + return load_json_schema_file(json_schema) + + +def sanitize_structured_output_schema( + schema: dict[str, Any], + *, + require_all_properties: bool = False, + additional_properties_false: bool = False, + strip_none_defaults: bool = True, +) -> dict[str, Any]: + """Return a provider-ready copy of a JSON Schema for structured outputs.""" + copied = deepcopy(schema) + return _sanitize_structured_output_schema_node( + copied, + copied, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + + +def _sanitize_structured_output_schema_node( + node: Any, + root: dict[str, Any], + *, + require_all_properties: bool, + additional_properties_false: bool, + strip_none_defaults: bool, +) -> Any: + if isinstance(node, list): + return [ + _sanitize_structured_output_schema_node( + item, + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + for item in node + ] + + if not isinstance(node, dict): + return node + + for defs_key in ("$defs", "definitions"): + defs = node.get(defs_key) + if isinstance(defs, dict): + node[defs_key] = { + key: _sanitize_structured_output_schema_node( + value, + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + for key, value in defs.items() + } + + properties = node.get("properties") + if isinstance(properties, dict): + if require_all_properties: + node["required"] = list(properties.keys()) + node["properties"] = { + key: _sanitize_structured_output_schema_node( + value, + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + for key, value in properties.items() + } + + if ( + additional_properties_false + and (node.get("type") == "object" or isinstance(properties, dict)) + and node.get("additionalProperties") is not False + ): + node["additionalProperties"] = False + + items = node.get("items") + if isinstance(items, dict): + node["items"] = _sanitize_structured_output_schema_node( + items, + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + + for union_key in ("anyOf", "oneOf"): + union = node.get(union_key) + if isinstance(union, list): + node[union_key] = [ + _sanitize_structured_output_schema_node( + item, + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + for item in union + ] + + all_of = node.get("allOf") + if isinstance(all_of, list): + if len(all_of) == 1 and isinstance(all_of[0], dict): + merged = _sanitize_structured_output_schema_node( + all_of[0], + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + node.update(merged) + node.pop("allOf", None) + else: + node["allOf"] = [ + _sanitize_structured_output_schema_node( + item, + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + for item in all_of + ] + + if strip_none_defaults and node.get("default") is None: + node.pop("default", None) + + ref = node.get("$ref") + if isinstance(ref, str) and len(node) > 1: + resolved = _resolve_local_ref(root, ref) + if isinstance(resolved, dict): + node.update({**resolved, **node}) + node.pop("$ref", None) + return _sanitize_structured_output_schema_node( + node, + root, + require_all_properties=require_all_properties, + additional_properties_false=additional_properties_false, + strip_none_defaults=strip_none_defaults, + ) + + return node + + +def _resolve_local_ref(root: dict[str, Any], ref: str) -> Any: + if not ref.startswith("#/"): + return None + + target: Any = root + for part in ref[2:].split("/"): + if not isinstance(target, dict): + return None + target = target.get(part.replace("~1", "/").replace("~0", "~")) + return target diff --git a/src/fast_agent/llm/usage_tracking.py b/src/fast_agent/llm/usage_tracking.py index 7f11db6c9..1da8342c9 100644 --- a/src/fast_agent/llm/usage_tracking.py +++ b/src/fast_agent/llm/usage_tracking.py @@ -342,10 +342,10 @@ def cumulative_reasoning_tokens(self) -> int: def cache_hit_rate(self) -> float | None: """Percentage of total input context served from cache""" cache_tokens = self.cumulative_cache_read_tokens + self.cumulative_cache_hit_tokens - total_input_context = self.cumulative_input_tokens + cache_tokens - if total_input_context == 0: + total_input_tokens = self.cumulative_input_tokens + if total_input_tokens == 0: return None - return (cache_tokens / total_input_context) * 100 + return (cache_tokens / total_input_tokens) * 100 @computed_field @property diff --git a/src/fast_agent/mcp/experimental_session_client.py b/src/fast_agent/mcp/experimental_session_client.py index 77b54c940..dd0427ba5 100644 --- a/src/fast_agent/mcp/experimental_session_client.py +++ b/src/fast_agent/mcp/experimental_session_client.py @@ -80,7 +80,7 @@ def size_bytes(self) -> int | None: @classmethod def from_environment(cls) -> JsonFileSessionCookieStore: - env_paths = resolve_environment_paths(override=os.getenv("ENVIRONMENT_DIR")) + env_paths = resolve_environment_paths() return cls(env_paths.root / "mcp-cookie.json") def load(self) -> dict[str, dict[str, Any]]: diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index 094bb3cd4..206e1e5d0 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -65,10 +65,13 @@ _resolve_oauth_mode, ) from fast_agent.mcp.skybridge import ( + MCP_APP_MIME_TYPE, SKYBRIDGE_MIME_TYPE, + AppIntegrationKind, SkybridgeResourceConfig, SkybridgeServerConfig, SkybridgeToolConfig, + extract_app_tool_metadata, ) from fast_agent.mcp.tool_execution_handler import NoOpToolExecutionHandler, ToolExecutionHandler from fast_agent.mcp.tool_permission_handler import NoOpToolPermissionHandler, ToolPermissionHandler @@ -928,17 +931,12 @@ async def _evaluate_skybridge_for_server( for namespaced_tool in tool_entries: tool_meta = namespaced_tool.tool.meta or {} - template_value = tool_meta.get("openai/outputTemplate") - if not template_value: - continue - try: - template_uri = AnyUrl(template_value) - except Exception as exc: - warning = ( - f"Tool '{namespaced_tool.namespaced_tool_name}' outputTemplate " - f"'{template_value}' is invalid: {exc}" + app_metadata = extract_app_tool_metadata( + tool_meta, namespaced_tool_name=namespaced_tool.namespaced_tool_name ) + except ValueError as exc: + warning = str(exc) config.warnings.append(warning) logger.error(warning) tool_configs.append( @@ -950,11 +948,21 @@ async def _evaluate_skybridge_for_server( ) continue + if app_metadata is None: + continue + + for metadata_warning in app_metadata.warnings: + warning = f"Tool '{namespaced_tool.namespaced_tool_name}' {metadata_warning}" + config.warnings.append(warning) + logger.warning(warning) + tool_configs.append( SkybridgeToolConfig( tool_name=namespaced_tool.tool.name, namespaced_tool_name=namespaced_tool.namespaced_tool_name, - template_uri=template_uri, + template_uri=app_metadata.resource_uri, + kind=app_metadata.kind, + visibility=app_metadata.visibility, ) ) @@ -972,6 +980,12 @@ async def _evaluate_skybridge_for_server( config.warnings.append(f"Failed to list resources: {exc}") return server_name, config + expected_mime_by_uri = { + str(tool.template_uri): tool.kind.expected_mime_type + for tool in tool_configs + if tool.template_uri is not None + } + for resource_entry in resources: uri = resource_entry.uri if not uri: @@ -989,7 +1003,11 @@ async def _evaluate_skybridge_for_server( logger.debug(warning) continue - sky_resource = SkybridgeResourceConfig(uri=uri_value) + entry_meta = getattr(resource_entry, "meta", None) + sky_resource = SkybridgeResourceConfig( + uri=uri_value, + meta=dict(entry_meta) if isinstance(entry_meta, dict) else {}, + ) config.ui_resources.append(sky_resource) try: @@ -1011,15 +1029,29 @@ async def _evaluate_skybridge_for_server( seen_mime_types.append(mime_type) if mime_type == SKYBRIDGE_MIME_TYPE: sky_resource.mime_type = mime_type + sky_resource.kind = AppIntegrationKind.SKYBRIDGE sky_resource.is_skybridge = True - break + elif mime_type == MCP_APP_MIME_TYPE: + sky_resource.mime_type = mime_type + sky_resource.kind = AppIntegrationKind.MCP_APP + sky_resource.is_mcp_app = True + + content_meta = getattr(content, "meta", None) + if isinstance(content_meta, dict): + sky_resource.meta.update(content_meta) if sky_resource.mime_type is None and seen_mime_types: sky_resource.mime_type = seen_mime_types[0] - if not sky_resource.is_skybridge: + if not sky_resource.is_valid_app_resource: observed_type = sky_resource.mime_type or "unknown MIME type" - warning = f"served as '{observed_type}' instead of '{SKYBRIDGE_MIME_TYPE}'" + expected_mime_type = expected_mime_by_uri.get(uri_str) + expected_label = ( + f"'{expected_mime_type}'" + if expected_mime_type + else f"'{SKYBRIDGE_MIME_TYPE}' or '{MCP_APP_MIME_TYPE}'" + ) + warning = f"served as '{observed_type}' instead of {expected_label}" sky_resource.warning = warning config.warnings.append(f"{uri_str}: {warning}") @@ -1030,9 +1062,14 @@ async def _evaluate_skybridge_for_server( resource_match = resource_lookup.get(str(tool_config.template_uri)) if not resource_match: + resource_label = ( + "Skybridge" + if tool_config.kind is AppIntegrationKind.SKYBRIDGE + else tool_config.kind.display_name + ) warning = ( f"Tool '{tool_config.namespaced_tool_name}' references missing " - f"Skybridge resource '{tool_config.template_uri}'" + f"{resource_label} resource '{tool_config.template_uri}'" ) tool_config.warning = warning config.warnings.append(warning) @@ -1040,13 +1077,18 @@ async def _evaluate_skybridge_for_server( continue tool_config.resource_uri = resource_match.uri - tool_config.is_valid = resource_match.is_skybridge + expected_mime_type = tool_config.kind.expected_mime_type + tool_config.is_valid = ( + resource_match.is_skybridge + if tool_config.kind is AppIntegrationKind.SKYBRIDGE + else resource_match.is_mcp_app + ) - if not resource_match.is_skybridge: + if not tool_config.is_valid: warning = ( f"Tool '{tool_config.namespaced_tool_name}' references resource " f"'{resource_match.uri}' served as '{resource_match.mime_type or 'unknown'}' " - f"instead of '{SKYBRIDGE_MIME_TYPE}'" + f"instead of '{expected_mime_type}'" ) tool_config.warning = warning config.warnings.append(warning) @@ -1057,7 +1099,7 @@ async def _evaluate_skybridge_for_server( valid_tool_count = sum(1 for tool in tool_configs if tool.is_valid) if config.enabled and valid_tool_count == 0: warning = ( - f"Skybridge resources detected on server '{server_name}' but no tools expose them" + f"App resources detected on server '{server_name}' but no tools expose them" ) config.warnings.append(warning) logger.warning(warning) @@ -1186,24 +1228,41 @@ async def list_tools(self) -> ListToolsResult: tools: list[Tool] = [] for namespaced_tool_name, namespaced_tool in self._namespaced_tool_map.items(): - tool_copy = namespaced_tool.tool.model_copy( - deep=True, update={"name": namespaced_tool_name} - ) skybridge_config = self._skybridge_configs.get(namespaced_tool.server_name) + discovered_tool = None + matching_tool = None if skybridge_config: - matching_tool = next( + discovered_tool = next( ( tool for tool in skybridge_config.tools - if tool.namespaced_tool_name == namespaced_tool_name and tool.is_valid + if tool.namespaced_tool_name == namespaced_tool_name ), None, ) - if matching_tool: - meta = dict(tool_copy.meta or {}) + if discovered_tool and discovered_tool.is_valid: + matching_tool = discovered_tool + + if discovered_tool and discovered_tool.is_app_only: + continue + + tool_copy = namespaced_tool.tool.model_copy( + deep=True, update={"name": namespaced_tool_name} + ) + if matching_tool: + meta = dict(tool_copy.meta or {}) + if matching_tool.kind is AppIntegrationKind.MCP_APP: + ui_meta = meta.get("ui") + ui_meta_dict = dict(ui_meta) if isinstance(ui_meta, dict) else {} + ui_meta_dict["resourceUri"] = str(matching_tool.template_uri) + ui_meta_dict["visibility"] = list(matching_tool.visibility) + meta["ui"] = ui_meta_dict + meta["ui/appEnabled"] = True + meta["ui/appTemplate"] = str(matching_tool.template_uri) + else: meta["openai/skybridgeEnabled"] = True meta["openai/skybridgeTemplate"] = str(matching_tool.template_uri) - tool_copy.meta = meta + tool_copy.meta = meta tools.append(tool_copy) return ListToolsResult(tools=tools) diff --git a/src/fast_agent/mcp/mcp_connection_manager.py b/src/fast_agent/mcp/mcp_connection_manager.py index 8ddb3fb13..d8b2d33d5 100644 --- a/src/fast_agent/mcp/mcp_connection_manager.py +++ b/src/fast_agent/mcp/mcp_connection_manager.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Callable, Union, cast import httpx -from anyio import Event, Lock, create_task_group +from anyio import CancelScope, Event, Lock, create_task_group from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx import HTTPStatusError from mcp import ClientSession @@ -35,6 +35,7 @@ from fast_agent.core.exceptions import ServerInitializationError from fast_agent.core.logging.logger import get_logger from fast_agent.event_progress import ProgressAction +from fast_agent.home import build_child_environment from fast_agent.mcp.interfaces import ClientSessionFactory from fast_agent.mcp.logger_textio import get_stderr_handler from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession @@ -201,6 +202,8 @@ def create_transport_context( config: MCPServerSettings, *, trigger_oauth: bool | None = None, + active_home: str | Path | None = None, + noenv: bool = False, ) -> AbstractAsyncContextManager: """ Create a transport context manager for the given server configuration. @@ -238,7 +241,12 @@ def create_transport_context( server_params = StdioServerParameters( command=config.command, args=config.args if config.args is not None else [], - env={**get_default_environment(), **(config.env or {})}, + env=build_child_environment( + active_home=active_home, + noenv=noenv, + base=get_default_environment(), + overrides=config.env, + ), cwd=config.cwd, ) error_handler = get_stderr_handler(server_name) @@ -343,6 +351,7 @@ def __init__( self._last_oauth_authorization_url: str | None = None self._oauth_abort_event = threading.Event() self._stdio_stderr_lines: deque[str] = deque(maxlen=STDIO_STDERR_BUFFER_LINES) + self._lifecycle_cancel_scope: CancelScope | None = None def is_healthy(self) -> bool: """Check if the server connection is healthy and ready to use.""" @@ -361,6 +370,12 @@ def request_shutdown(self) -> None: self._oauth_abort_event.set() self._shutdown_event.set() + def cancel_lifecycle(self) -> None: + """Request shutdown and cancel the lifecycle task if it is still blocked.""" + self.request_shutdown() + if self._lifecycle_cancel_scope is not None: + self._lifecycle_cancel_scope.cancel() + async def wait_for_shutdown_request(self) -> None: """ Wait until the shutdown event is set. @@ -632,6 +647,16 @@ async def _server_lifecycle_task(server_conn: ServerConnection) -> None: task group. Any exceptions must be caught and handled gracefully, with errors recorded in server_conn._error_occurred and _error_message. """ + with CancelScope() as cancel_scope: + server_conn._lifecycle_cancel_scope = cancel_scope + try: + await _run_server_lifecycle(server_conn) + finally: + server_conn._lifecycle_cancel_scope = None + + +async def _run_server_lifecycle(server_conn: ServerConnection) -> None: + """Run the server lifecycle inside the connection-owned cancellation scope.""" server_name = server_conn.server_name try: transport_context = server_conn._transport_context_factory() @@ -1105,7 +1130,16 @@ def prepare_http_transport_auth(*, suppress_transport_errors: Callable[[], None] server_params = StdioServerParameters( command=config.command, args=config.args if config.args is not None else [], - env={**get_default_environment(), **(config.env or {})}, + env=build_child_environment( + active_home=getattr(self.context.config, "_fast_agent_home", None) + if self.context.config + else None, + noenv=bool(getattr(self.context.config, "_fast_agent_noenv", False)) + if self.context.config + else False, + base=get_default_environment(), + overrides=config.env, + ), cwd=config.cwd, ) # Create custom error handler to ensure all output is captured @@ -1223,7 +1257,7 @@ async def _launch_and_wait_for_server( try: await _wait_for_initialized_with_startup_budget(server_conn, startup_timeout_seconds) except asyncio.CancelledError: - server_conn.request_shutdown() + server_conn.cancel_lifecycle() async with self._lock: current = self.running_servers.get(server_name) if current is server_conn: @@ -1232,7 +1266,7 @@ async def _launch_and_wait_for_server( self._server_oauth_active.pop(server_name, None) raise except TimeoutError as exc: - server_conn.request_shutdown() + server_conn.cancel_lifecycle() async with self._lock: current = self.running_servers.get(server_name) if current is server_conn: @@ -1257,7 +1291,7 @@ async def _clear_running_server_state( server_name: str, server_conn: ServerConnection, ) -> None: - server_conn.request_shutdown() + server_conn.cancel_lifecycle() async with self._lock: current = self.running_servers.get(server_name) if current is server_conn: @@ -1352,6 +1386,7 @@ async def get_server( if server_conn.is_healthy(): return server_conn + await self._clear_running_server_state(server_name, server_conn) error_msg = server_conn._error_message or "Unknown error" if isinstance(error_msg, list): @@ -1385,7 +1420,7 @@ async def get_server( ) raise ServerInitializationError( - f"MCP Server: '{server_name}': Failed to initialize - see details. Check fastagent.config.yaml?", + f"MCP Server: '{server_name}': Failed to initialize - see details. Check fast-agent.yaml?", _append_stdio_stderr_details(server_conn, formatted_error), ) @@ -1471,6 +1506,7 @@ async def reconnect_server( logger.info(f"{server_name}: Reconnection successful") return server_conn + await self._clear_running_server_state(server_name, server_conn) error_msg = server_conn._error_message or "Unknown error during reconnection" if isinstance(error_msg, list): diff --git a/src/fast_agent/mcp/skybridge.py b/src/fast_agent/mcp/skybridge.py index cee0f240c..7299789ba 100644 --- a/src/fast_agent/mcp/skybridge.py +++ b/src/fast_agent/mcp/skybridge.py @@ -1,25 +1,138 @@ +from enum import StrEnum +from typing import Any + from pydantic import AnyUrl, BaseModel, Field SKYBRIDGE_MIME_TYPE = "text/html+skybridge" +MCP_APP_MIME_TYPE = "text/html;profile=mcp-app" + +OPENAI_OUTPUT_TEMPLATE_KEY = "openai/outputTemplate" +MCP_APP_RESOURCE_URI_KEY = "ui/resourceUri" + + +class AppIntegrationKind(StrEnum): + """Interactive UI integration variants discovered from MCP metadata.""" + + SKYBRIDGE = "skybridge" + MCP_APP = "mcp_app" + + @property + def display_name(self) -> str: + if self is AppIntegrationKind.MCP_APP: + return "MCP Apps" + return "OpenAI Apps SDK" + + @property + def expected_mime_type(self) -> str: + if self is AppIntegrationKind.MCP_APP: + return MCP_APP_MIME_TYPE + return SKYBRIDGE_MIME_TYPE + + +class AppToolMetadata(BaseModel): + """Normalized app metadata extracted from a tool.""" + + resource_uri: AnyUrl + kind: AppIntegrationKind + visibility: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + @property + def is_app_only(self) -> bool: + return set(self.visibility) == {"app"} + + +def _ui_meta(meta: dict[str, Any]) -> dict[str, Any]: + ui = meta.get("ui") + if isinstance(ui, dict): + return ui + return {} + + +def _visibility(meta: dict[str, Any]) -> tuple[list[str], list[str]]: + ui = _ui_meta(meta) + raw_visibility = ui.get("visibility") + if raw_visibility is None: + return ["model", "app"], [] + if not isinstance(raw_visibility, list) or not all( + isinstance(value, str) for value in raw_visibility + ): + return ["model", "app"], ["invalid _meta.ui.visibility; expected list[str]"] + + visibility = [value for value in raw_visibility if value in {"model", "app"}] + invalid = sorted(set(raw_visibility) - {"model", "app"}) + warnings = ( + [f"invalid _meta.ui.visibility values ignored: {', '.join(invalid)}"] + if invalid + else [] + ) + return visibility or ["model", "app"], warnings + + +def extract_app_tool_metadata( + meta: dict[str, Any], *, namespaced_tool_name: str +) -> AppToolMetadata | None: + """Return normalized app metadata for either MCP Apps or Skybridge tools.""" + + warnings: list[str] = [] + ui = _ui_meta(meta) + resource_value = ui.get("resourceUri") + kind = AppIntegrationKind.MCP_APP + + if not isinstance(resource_value, str) or not resource_value: + resource_value = meta.get(MCP_APP_RESOURCE_URI_KEY) + + if not isinstance(resource_value, str) or not resource_value: + resource_value = meta.get(OPENAI_OUTPUT_TEMPLATE_KEY) + kind = AppIntegrationKind.SKYBRIDGE + + if not isinstance(resource_value, str) or not resource_value: + return None + + try: + resource_uri = AnyUrl(resource_value) + except Exception as exc: + raise ValueError( + f"Tool '{namespaced_tool_name}' resource URI '{resource_value}' is invalid: {exc}" + ) from exc + + visibility, visibility_warnings = _visibility(meta) + warnings.extend(visibility_warnings) + + return AppToolMetadata( + resource_uri=resource_uri, + kind=kind, + visibility=visibility, + warnings=warnings, + ) class SkybridgeResourceConfig(BaseModel): - """Represents a Skybridge (apps SDK) resource exposed by an MCP server.""" + """Represents an interactive app resource exposed by an MCP server.""" uri: AnyUrl mime_type: str | None = None + kind: AppIntegrationKind | None = None is_skybridge: bool = False + is_mcp_app: bool = False warning: str | None = None + meta: dict[str, Any] = Field(default_factory=dict) + + @property + def is_valid_app_resource(self) -> bool: + return self.is_skybridge or self.is_mcp_app class SkybridgeToolConfig(BaseModel): - """Represents Skybridge metadata discovered for a tool.""" + """Represents interactive app metadata discovered for a tool.""" tool_name: str namespaced_tool_name: str template_uri: AnyUrl | None = None resource_uri: AnyUrl | None = None + kind: AppIntegrationKind = AppIntegrationKind.SKYBRIDGE + visibility: list[str] = Field(default_factory=list) is_valid: bool = False warning: str | None = None @@ -27,9 +140,13 @@ class SkybridgeToolConfig(BaseModel): def display_name(self) -> str: return self.namespaced_tool_name or self.tool_name + @property + def is_app_only(self) -> bool: + return set(self.visibility) == {"app"} + class SkybridgeServerConfig(BaseModel): - """Skybridge configuration discovered for a specific MCP server.""" + """Interactive app configuration discovered for a specific MCP server.""" server_name: str supports_resources: bool = False @@ -39,6 +156,17 @@ class SkybridgeServerConfig(BaseModel): @property def enabled(self) -> bool: - """Return True when at least one resource advertises the Skybridge MIME type.""" - return any(resource.is_skybridge for resource in self.ui_resources) + """Return True when at least one resource advertises a supported app MIME type.""" + return any(resource.is_valid_app_resource for resource in self.ui_resources) + + @property + def has_mcp_apps(self) -> bool: + return any( + resource.is_mcp_app for resource in self.ui_resources + ) or any(tool.kind is AppIntegrationKind.MCP_APP for tool in self.tools) + @property + def has_skybridge(self) -> bool: + return any( + resource.is_skybridge for resource in self.ui_resources + ) or any(tool.kind is AppIntegrationKind.SKYBRIDGE for tool in self.tools) diff --git a/src/fast_agent/mcp_server_registry.py b/src/fast_agent/mcp_server_registry.py index 5443b50d1..945aa2afc 100644 --- a/src/fast_agent/mcp_server_registry.py +++ b/src/fast_agent/mcp_server_registry.py @@ -50,6 +50,7 @@ def __init__( config_path (str): Path to the YAML configuration file. """ self._init_results: dict[str, "InitializeResult"] = {} + self._config = config if config is not None and config.mcp is not None: self.registry = config.mcp.servers or {} @@ -142,6 +143,8 @@ async def _initialized_session(oauth_enabled: bool) -> AsyncIterator[ClientSessi server_name=server_name, config=config, trigger_oauth=oauth_enabled, + active_home=getattr(self._config, "_fast_agent_home", None), + noenv=bool(getattr(self._config, "_fast_agent_noenv", False)), ) async with transport_context as (read_stream, write_stream, _get_session_id_cb): diff --git a/src/fast_agent/paths.py b/src/fast_agent/paths.py index 4b27828dc..6570320fb 100644 --- a/src/fast_agent/paths.py +++ b/src/fast_agent/paths.py @@ -1,10 +1,12 @@ from __future__ import annotations +import os from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from fast_agent.constants import DEFAULT_ENVIRONMENT_DIR, DEFAULT_SKILLS_PATHS +from fast_agent.home import resolve_fast_agent_home if TYPE_CHECKING: from fast_agent.config import Settings @@ -28,6 +30,15 @@ def _resolve_relative_path(path: Path, base: Path) -> Path: return (base / path).resolve() +def _is_ambient_legacy_environment_dir(value: str | Path | None) -> bool: + if value is None: + return False + legacy_value = os.getenv("ENVIRONMENT_DIR") + if not legacy_value: + return False + return Path(value).expanduser() == Path(legacy_value).expanduser() + + def resolve_environment_dir( settings: "Settings | None" = None, *, @@ -41,13 +52,28 @@ def resolve_environment_dir( from fast_agent.config import get_settings settings = get_settings() - environment_dir = getattr(settings, "environment_dir", None) - - if environment_dir is None: - env_path = Path(DEFAULT_ENVIRONMENT_DIR) - else: + if settings._fast_agent_noenv: + raise ValueError("fast-agent home is disabled for these settings") + configured_environment_dir = settings.environment_dir + if ( + configured_environment_dir is not None + and not _is_ambient_legacy_environment_dir(configured_environment_dir) + ): + environment_dir = configured_environment_dir + env_path = Path(environment_dir).expanduser() + return _resolve_relative_path(env_path, base) + if settings._fast_agent_home is not None: + return Path(settings._fast_agent_home).expanduser().resolve() + + if environment_dir is not None: env_path = Path(environment_dir).expanduser() - return _resolve_relative_path(env_path, base) + return _resolve_relative_path(env_path, base) + + home = resolve_fast_agent_home(cwd=base) + if home is not None: + return home.path + + return _resolve_relative_path(Path(DEFAULT_ENVIRONMENT_DIR), base) def resolve_environment_paths( @@ -76,12 +102,22 @@ def default_skill_paths( override: str | Path | None = None, ) -> list[Path]: base = cwd or Path.cwd() - env_paths = resolve_environment_paths(settings=settings, cwd=base, override=override) + if settings is None: + from fast_agent.config import Settings + + settings = Settings() + env_paths = ( + None + if override is None and settings._fast_agent_noenv + else resolve_environment_paths(settings=settings, cwd=base, override=override) + ) resolved: list[Path] = [] env_skills_entry = Path(DEFAULT_ENVIRONMENT_DIR) / "skills" for entry in DEFAULT_SKILLS_PATHS: raw_path = Path(entry).expanduser() if raw_path == env_skills_entry: + if env_paths is None: + continue path = env_paths.skills else: path = _resolve_relative_path(raw_path, base) diff --git a/src/fast_agent/privacy/privacy_filter_onnx.py b/src/fast_agent/privacy/privacy_filter_onnx.py index 7c61e1024..0e871e80b 100644 --- a/src/fast_agent/privacy/privacy_filter_onnx.py +++ b/src/fast_agent/privacy/privacy_filter_onnx.py @@ -21,6 +21,8 @@ TraceSanitizer, ) from fast_agent.privacy.viterbi import ( + VITERBI_TRANSITION_BIAS_KEYS, + ZERO_TRANSITION_BIASES, ViterbiTables, build_viterbi_tables, constrained_viterbi_np, @@ -41,7 +43,10 @@ "private_url": "", "secret": "", } -_DEFAULT_MAX_WINDOW_TOKENS = 4096 +# CPU trace export intentionally uses a much smaller inference chunk than the +# model's advertised default_n_ctx (currently 128k). 4096 keeps ORT memory and +# latency manageable while still dwarfing the model's local attention radius. +_DEFAULT_INFERENCE_WINDOW_TOKENS = 4096 _DEFAULT_WINDOW_OVERLAP_TOKENS = 128 _DEFAULT_DEVICE: Literal["auto"] = "auto" _SUPPORTED_DEVICES = ("auto", "cpu", "cuda") @@ -80,12 +85,19 @@ def __init__( self._config = _load_json(self._files.config) self._labels = _load_labels(self._config) self._tokenizer, self._session, self._np = self._load_runtime() - self._viterbi_tables: ViterbiTables = build_viterbi_tables(self._labels, self._np) + transition_biases = _load_viterbi_transition_biases( + self._model_dir / "viterbi_calibration.json" + ) + self._viterbi_tables: ViterbiTables = build_viterbi_tables( + self._labels, + self._np, + transition_biases=transition_biases, + ) session_providers = list(self._session.get_providers()) self._active_provider = session_providers[0] if session_providers else None self._max_window_tokens = _env_int( "FAST_AGENT_PRIVACY_FILTER_MAX_WINDOW_TOKENS", - default=_DEFAULT_MAX_WINDOW_TOKENS, + default=_DEFAULT_INFERENCE_WINDOW_TOKENS, minimum=128, ) self._window_overlap_tokens = _env_int( @@ -372,6 +384,46 @@ def _validate_labels(labels: list[str]) -> list[str]: return labels +def _load_viterbi_transition_biases(path: Path) -> dict[str, float]: + """Load optional calibrated BIOES transition biases. + + The current OpenAI Privacy Filter calibration file contains all zeroes, so + this is usually behavior-preserving. Keeping support here lets newer model + revisions tune precision/recall without code changes. + """ + + if not path.is_file(): + return dict(ZERO_TRANSITION_BIASES) + payload = _load_json(path) + raw_biases: object = payload + operating_points = payload.get("operating_points") + if operating_points is not None: + if not isinstance(operating_points, dict): + raise SessionExportPrivacyFilterError( + f"Invalid Viterbi calibration operating_points in {path}." + ) + default = operating_points.get("default") + if isinstance(default, dict): + raw_biases = default.get("biases") + else: + raw_biases = None + + if raw_biases in (None, {}): + return dict(ZERO_TRANSITION_BIASES) + if not isinstance(raw_biases, dict): + raise SessionExportPrivacyFilterError(f"Invalid Viterbi calibration biases in {path}.") + + biases = dict(ZERO_TRANSITION_BIASES) + for key in VITERBI_TRANSITION_BIAS_KEYS: + raw_value = raw_biases.get(key, 0.0) + if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float)): + raise SessionExportPrivacyFilterError( + f"Invalid Viterbi calibration value {key!r} in {path}." + ) + biases[key] = float(raw_value) + return biases + + def _real_token_indices(offsets: list[tuple[int, int]]) -> list[int]: return [index for index, (start, end) in enumerate(offsets) if end > start] diff --git a/src/fast_agent/privacy/viterbi.py b/src/fast_agent/privacy/viterbi.py index f1c9a4e99..d4b0ead59 100644 --- a/src/fast_agent/privacy/viterbi.py +++ b/src/fast_agent/privacy/viterbi.py @@ -6,6 +6,15 @@ from typing import Any IMPOSSIBLE = -1_000_000_000.0 +VITERBI_TRANSITION_BIAS_KEYS = ( + "transition_bias_background_stay", + "transition_bias_background_to_start", + "transition_bias_inside_to_continue", + "transition_bias_inside_to_end", + "transition_bias_end_to_background", + "transition_bias_end_to_start", +) +ZERO_TRANSITION_BIASES = {key: 0.0 for key in VITERBI_TRANSITION_BIAS_KEYS} @dataclass(frozen=True, slots=True) @@ -31,15 +40,23 @@ class ViterbiTables: end_mask: Any # ndarray (L,) -def build_viterbi_tables(labels: list[str], np_module: Any) -> ViterbiTables: +def build_viterbi_tables( + labels: list[str], + np_module: Any, + *, + transition_biases: dict[str, float] | None = None, +) -> ViterbiTables: """Build numpy transition tables for a label set. Call once per session.""" + biases = ZERO_TRANSITION_BIASES | (transition_biases or {}) label_count = len(labels) transitions = np_module.full((label_count, label_count), IMPOSSIBLE, dtype=np_module.float32) for previous_index, previous_label in enumerate(labels): for current_index, current_label in enumerate(labels): if _valid_transition(previous_label, current_label): - transitions[previous_index, current_index] = 0.0 + transitions[previous_index, current_index] = _transition_bias( + previous_label, current_label, biases + ) start_mask = np_module.array( [0.0 if _valid_start(label) else IMPOSSIBLE for label in labels], dtype=np_module.float32, @@ -195,6 +212,28 @@ def _valid_transition(previous: str, current: str) -> bool: return False +def _transition_bias(previous: str, current: str, biases: dict[str, float]) -> float: + previous_prefix, _ = _split_label(previous) + current_prefix, _ = _split_label(current) + if previous_prefix == "O": + return ( + biases["transition_bias_background_stay"] + if current_prefix == "O" + else biases["transition_bias_background_to_start"] + ) + if previous_prefix in {"B", "I"}: + return ( + biases["transition_bias_inside_to_continue"] + if current_prefix == "I" + else biases["transition_bias_inside_to_end"] + ) + return ( + biases["transition_bias_end_to_background"] + if current_prefix == "O" + else biases["transition_bias_end_to_start"] + ) + + def _split_label(label: str) -> tuple[str, str | None]: if label == "O": return "O", None diff --git a/src/fast_agent/session/hydrator.py b/src/fast_agent/session/hydrator.py index 7447295bc..60f0d3e91 100644 --- a/src/fast_agent/session/hydrator.py +++ b/src/fast_agent/session/hydrator.py @@ -352,6 +352,12 @@ def _resolve_model_spec( if overlay_spec is not None: return overlay_spec + model_spec = agent_snapshot.model_spec + if model_spec is not None: + stripped_model_spec = model_spec.strip() + if stripped_model_spec: + return stripped_model_spec + model_name = agent_snapshot.model if model_name is None: return None diff --git a/src/fast_agent/session/session_manager.py b/src/fast_agent/session/session_manager.py index 4a3aa7d68..dee6ae83a 100644 --- a/src/fast_agent/session/session_manager.py +++ b/src/fast_agent/session/session_manager.py @@ -23,6 +23,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Mapping +from fast_agent.constants import DEFAULT_ENVIRONMENT_DIR from fast_agent.core.logging.logger import get_logger from fast_agent.paths import resolve_environment_paths from fast_agent.session.snapshot import ( @@ -58,21 +59,37 @@ def _normalized_environment_override(cwd: pathlib.Path) -> str | None: - """Return ENVIRONMENT_DIR as an absolute path string when set.""" - override = os.getenv("ENVIRONMENT_DIR") - if not override: + """Return the active environment override as an absolute path string when set.""" + from fast_agent.home import resolve_fast_agent_home + + home = resolve_fast_agent_home(cwd=cwd) + if home is None or home.source == "default": return None + return str(home.path) + - path = pathlib.Path(override).expanduser() - if not path.is_absolute(): - path = (cwd / path).resolve() - else: - path = path.resolve() +def _session_environment_override( + *, + cwd: pathlib.Path, + explicit_cwd: bool, + environment_override: str | pathlib.Path | None, + respect_env_override: bool, +) -> str | pathlib.Path | None: + if environment_override is not None or not respect_env_override: + return environment_override + + env_override = _normalized_environment_override(cwd) + if env_override is not None: + return env_override + + if explicit_cwd: + from fast_agent.config import get_settings - normalized = str(path) - if normalized != override: - os.environ["ENVIRONMENT_DIR"] = normalized - return normalized + settings = get_settings() + if settings.environment_dir is None and settings._fast_agent_home_source == "default": + return DEFAULT_ENVIRONMENT_DIR + + return None def display_session_name(name: str) -> str: @@ -572,10 +589,14 @@ def __init__( respect_env_override: bool = True, ) -> None: """Initialize session manager.""" + explicit_cwd = cwd is not None base = (cwd or pathlib.Path.cwd()).resolve() - env_override = environment_override - if env_override is None: - env_override = _normalized_environment_override(base) if respect_env_override else None + env_override = _session_environment_override( + cwd=base, + explicit_cwd=explicit_cwd, + environment_override=environment_override, + respect_env_override=respect_env_override, + ) env_paths = resolve_environment_paths(cwd=base, override=env_override) self.workspace_dir = base self.base_dir = env_paths.sessions @@ -1087,24 +1108,26 @@ def get_session_manager( ) -> SessionManager: """Get or create the global session manager.""" global _session_manager + explicit_cwd = cwd is not None resolved_cwd = cwd.resolve() if cwd is not None else pathlib.Path.cwd().resolve() - env_override = environment_override - if env_override is None: - env_override = ( - _normalized_environment_override(resolved_cwd) if respect_env_override else None - ) + env_override = _session_environment_override( + cwd=resolved_cwd, + explicit_cwd=explicit_cwd, + environment_override=environment_override, + respect_env_override=respect_env_override, + ) expected_paths = resolve_environment_paths(cwd=resolved_cwd, override=env_override) if _session_manager is None: _session_manager = SessionManager( cwd=cwd, - environment_override=environment_override, + environment_override=env_override, respect_env_override=respect_env_override, ) return _session_manager if _session_manager.base_dir != expected_paths.sessions: _session_manager = SessionManager( cwd=cwd, - environment_override=environment_override, + environment_override=env_override, respect_env_override=respect_env_override, ) elif _session_manager.workspace_dir != resolved_cwd: diff --git a/src/fast_agent/session/trace_export_codex.py b/src/fast_agent/session/trace_export_codex.py index 417986b6a..c3374eb4c 100644 --- a/src/fast_agent/session/trace_export_codex.py +++ b/src/fast_agent/session/trace_export_codex.py @@ -22,7 +22,13 @@ TextResourceContents, ) -from fast_agent.constants import FAST_AGENT_USAGE, REASONING +from fast_agent.constants import ( + ANTHROPIC_SERVER_TOOLS_CHANNEL, + FAST_AGENT_TIMING, + FAST_AGENT_USAGE, + OPENAI_ASSISTANT_MESSAGE_ITEMS, + REASONING, +) from fast_agent.llm.model_database import ModelDatabase from fast_agent.mcp.helpers.content_helpers import ( canonicalize_tool_result_content_for_llm, @@ -387,6 +393,175 @@ def _developer_message_item( } +def _content_item_from_mapping( + item: dict[str, object], + *, + output_text: bool, + sanitization: _TextSanitization | None = None, +) -> dict[str, object] | None: + item_type = _string_field(item, "type") + if item_type in {"input_text", "output_text"}: + text = _string_field(item, "text") + if text is None: + return None + normalized_type = "output_text" if output_text else "input_text" + content_item: dict[str, object] = { + "type": normalized_type, + "text": _sanitize_text(sanitization, text), + } + annotations = item.get("annotations") + if isinstance(annotations, list): + content_item["annotations"] = annotations + return content_item + + if item_type == "input_image": + image_url = _string_field(item, "image_url") + if image_url is None: + return None + content_item: dict[str, object] = {"type": "input_image", "image_url": image_url} + detail = _string_field(item, "detail") + if detail is not None: + content_item["detail"] = detail + return content_item + + return None + + +def _raw_assistant_message_items( + message: PromptMessageExtended, + sanitization: _TextSanitization | None = None, +) -> list[dict[str, object]]: + """Return provider-captured assistant message items in Codex-compatible shape. + + OpenAI Responses can emit multiple assistant message items in one fast-agent history + message (for example commentary followed by final_answer). The channel preserves that + item boundary and phase metadata; prefer it over reconstructing a single collapsed item. + """ + + items: list[dict[str, object]] = [] + for payload in _json_channel_payloads(message, OPENAI_ASSISTANT_MESSAGE_ITEMS): + if payload.get("type") != "message": + continue + + role = _string_field(payload, "role") or "assistant" + if role != "assistant": + continue + + content_items: list[dict[str, object]] = [] + content = payload.get("content") + if isinstance(content, list): + for raw_content_item in content: + content_item = _content_item_from_mapping( + _object_mapping(raw_content_item) or {}, + output_text=True, + sanitization=sanitization, + ) + if content_item is not None: + content_items.append(content_item) + + if not content_items: + continue + + item: dict[str, object] = { + "type": "message", + "role": "assistant", + "content": content_items, + } + phase = _string_field(payload, "phase") + if phase is not None: + item["phase"] = phase + items.append(item) + + return items + + +def _server_tool_input(payload: dict[str, object]) -> dict[str, object] | None: + input_payload = _object_mapping(payload.get("input")) + if input_payload is not None: + return input_payload + return payload + + +def _string_list_field(mapping: dict[str, object] | None, key: str) -> list[str]: + if mapping is None: + return [] + value = mapping.get(key) + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str) and item] + + +def _web_search_action( + payload: dict[str, object], + sanitization: _TextSanitization | None = None, +) -> dict[str, object] | None: + tool_input = _server_tool_input(payload) + action = _string_field(payload, "action") + query = _string_field(tool_input, "query") or _string_field(payload, "query") + queries = _string_list_field(tool_input, "queries") or _string_list_field(payload, "queries") + url = _string_field(tool_input, "url") or _string_field(payload, "url") + pattern = _string_field(tool_input, "pattern") or _string_field(payload, "pattern") + + if query is not None: + query = _sanitize_text(sanitization, query) + queries = [_sanitize_text(sanitization, item) for item in queries] + if pattern is not None: + pattern = _sanitize_text(sanitization, pattern) + + if action in {"open_page", "open_url", "fetch"} or (url is not None and not query and not queries): + result: dict[str, object] = {"type": "open_page"} + if url is not None: + result["url"] = url + return result + + if action in {"find_in_page", "find"} or (url is not None and pattern is not None): + result: dict[str, object] = {"type": "find_in_page"} + if url is not None: + result["url"] = url + if pattern is not None: + result["pattern"] = pattern + return result + + if query is None and not queries: + return None + + result: dict[str, object] = {"type": "search"} + if query is not None: + result["query"] = query + if queries: + result["queries"] = queries + return result + + +def _server_tool_response_items( + message: PromptMessageExtended, + sanitization: _TextSanitization | None = None, +) -> list[dict[str, object]]: + items: list[dict[str, object]] = [] + + for payload in _json_channel_payloads(message, ANTHROPIC_SERVER_TOOLS_CHANNEL): + if payload.get("type") != "server_tool_use": + continue + + tool_name = _string_field(payload, "name") + if tool_name not in {"web_search", "web_fetch", "web_search_call"}: + continue + + item: dict[str, object] = {"type": "web_search_call"} + item_id = _string_field(payload, "id") + if item_id is not None: + item["id"] = item_id + status = _string_field(payload, "status") + if status is not None: + item["status"] = status + action = _web_search_action(payload, sanitization=sanitization) + if action is not None: + item["action"] = action + items.append(item) + + return items + + def _assistant_message_item( message: PromptMessageExtended, sanitization: _TextSanitization | None = None, @@ -527,25 +702,56 @@ def _int_field(mapping: dict[str, object] | None, key: str) -> int | None: return None -def _message_usage_payload(message: PromptMessageExtended) -> dict[str, object] | None: +def _float_field(mapping: dict[str, object] | None, key: str) -> float | None: + if mapping is None: + return None + value = mapping.get(key) + if isinstance(value, bool): + return None + if isinstance(value, int | float): + return float(value) + return None + + +def _milliseconds_field(mapping: dict[str, object] | None, key: str) -> int | None: + value = _float_field(mapping, key) + if value is None: + return None + return max(0, round(value)) + + +def _json_channel_payloads( + message: PromptMessageExtended, + channel_name: str, +) -> list[dict[str, object]]: channels = message.channels if channels is None: - return None + return [] - blocks = channels.get(FAST_AGENT_USAGE) + blocks = channels.get(channel_name) if blocks is None: - return None + return [] - for text in reversed(_message_texts(blocks)): + payloads: list[dict[str, object]] = [] + for text in _message_texts(blocks): try: payload = json.loads(text) except json.JSONDecodeError: continue parsed = _object_mapping(payload) if parsed is not None: - return parsed + payloads.append(parsed) + return payloads - return None + +def _message_usage_payload(message: PromptMessageExtended) -> dict[str, object] | None: + payloads = _json_channel_payloads(message, FAST_AGENT_USAGE) + return payloads[-1] if payloads else None + + +def _message_timing_payload(message: PromptMessageExtended) -> dict[str, object] | None: + payloads = _json_channel_payloads(message, FAST_AGENT_TIMING) + return payloads[0] if payloads else None def _usage_turn_payload(message: PromptMessageExtended) -> dict[str, object] | None: @@ -656,15 +862,7 @@ def _cached_input_tokens(turn_payload: dict[str, object] | None) -> int | None: return _int_field(cache_payload, "cache_hit_tokens") -def _token_count_payload( - message: PromptMessageExtended, - *, - model_context_window: int | None, -) -> dict[str, object] | None: - turn_payload = _usage_turn_payload(message) - if turn_payload is None: - return None - +def _token_usage_from_turn_payload(turn_payload: dict[str, object]) -> dict[str, object] | None: token_usage: dict[str, object] = {} input_tokens = _int_field(turn_payload, "display_input_tokens") @@ -692,8 +890,69 @@ def _token_count_payload( if not token_usage: return None + return token_usage + + +def _cached_input_tokens_from_summary(summary_payload: dict[str, object] | None) -> int | None: + cache_read_tokens = _int_field(summary_payload, "cumulative_cache_read_tokens") + cache_hit_tokens = _int_field(summary_payload, "cumulative_cache_hit_tokens") + total = (cache_read_tokens or 0) + (cache_hit_tokens or 0) + return total if total > 0 else None + + +def _token_usage_from_summary_payload( + summary_payload: dict[str, object] | None, +) -> dict[str, object] | None: + token_usage: dict[str, object] = {} + + input_tokens = _int_field(summary_payload, "cumulative_input_tokens") + if input_tokens is not None: + token_usage["input_tokens"] = input_tokens + + cached_input_tokens = _cached_input_tokens_from_summary(summary_payload) + if cached_input_tokens is not None: + token_usage["cached_input_tokens"] = cached_input_tokens + + output_tokens = _int_field(summary_payload, "cumulative_output_tokens") + if output_tokens is not None: + token_usage["output_tokens"] = output_tokens + + reasoning_output_tokens = _int_field(summary_payload, "cumulative_reasoning_tokens") + if reasoning_output_tokens is not None: + token_usage["reasoning_output_tokens"] = reasoning_output_tokens + + total_tokens = _int_field(summary_payload, "cumulative_billing_tokens") + if total_tokens is None: + total_tokens = _int_field(summary_payload, "current_context_tokens") + if total_tokens is not None: + token_usage["total_tokens"] = total_tokens + + if not token_usage: + return None + + return token_usage + + +def _token_count_payload( + message: PromptMessageExtended, + *, + model_context_window: int | None, +) -> dict[str, object] | None: + turn_payload = _usage_turn_payload(message) + if turn_payload is None: + return None + + last_token_usage = _token_usage_from_turn_payload(turn_payload) + if last_token_usage is None: + return None + + total_token_usage = _token_usage_from_summary_payload(_usage_summary_payload(message)) + if total_token_usage is None: + total_token_usage = dict(last_token_usage) + info: dict[str, object] = { - "last_token_usage": token_usage, + "total_token_usage": total_token_usage, + "last_token_usage": last_token_usage, } if model_context_window is not None: info["model_context_window"] = model_context_window @@ -783,7 +1042,7 @@ def _turn_started_payload( started_at: datetime | None, ) -> dict[str, object]: payload: dict[str, object] = { - "type": "turn_started", + "type": "task_started", "turn_id": turn_id, "collaboration_mode_kind": "default", } @@ -814,10 +1073,14 @@ def _user_event_payload( def _turn_complete_payload( turn_id: str, last_agent_message: str | None, + *, + completed_at: datetime | None = None, + duration_ms: int | None = None, + time_to_first_token_ms: int | None = None, sanitization: _TextSanitization | None = None, ) -> dict[str, object]: - return { - "type": "turn_complete", + payload: dict[str, object] = { + "type": "task_complete", "turn_id": turn_id, "last_agent_message": ( None @@ -825,6 +1088,14 @@ def _turn_complete_payload( else _sanitize_text(sanitization, last_agent_message) ), } + completed_at_timestamp = _timestamp_or_none(completed_at) + if completed_at_timestamp is not None: + payload["completed_at"] = completed_at_timestamp + if duration_ms is not None: + payload["duration_ms"] = duration_ms + if time_to_first_token_ms is not None: + payload["time_to_first_token_ms"] = time_to_first_token_ms + return payload def _response_items( @@ -844,9 +1115,15 @@ def _response_items( if reasoning_item is not None: items.append(reasoning_item) - assistant_item = _assistant_message_item(message, sanitization=sanitization) - if assistant_item is not None: - items.append(assistant_item) + items.extend(_server_tool_response_items(message, sanitization=sanitization)) + + raw_assistant_items = _raw_assistant_message_items(message, sanitization=sanitization) + if raw_assistant_items: + items.extend(raw_assistant_items) + else: + assistant_item = _assistant_message_item(message, sanitization=sanitization) + if assistant_item is not None: + items.append(assistant_item) items.extend(_function_call_items(message, sanitization=sanitization)) return items @@ -856,11 +1133,61 @@ def _is_turn_start(message: PromptMessageExtended) -> bool: return message.role == "user" and not message.tool_results +def _elapsed_ms(started_at: datetime | None, completed_at: datetime | None) -> int | None: + if started_at is None or completed_at is None: + return None + elapsed_ms = (_normalize_utc(completed_at) - _normalize_utc(started_at)).total_seconds() * 1000 + if elapsed_ms <= 0: + return None + return round(elapsed_ms) + + +def _message_time_to_first_token_ms(message: PromptMessageExtended) -> int | None: + timing_payload = _message_timing_payload(message) + for key in ( + "ttft_ms", + "time_to_first_token_ms", + "first_token_ms", + "first_token_latency_ms", + "time_to_response_ms", + ): + value = _milliseconds_field(timing_payload, key) + if value is not None: + return value + return None + + @dataclass(slots=True) class _TurnState: turn_id: str + started_at: datetime | None = None + completed_at: datetime | None = None + llm_duration_ms: int = 0 + time_to_first_token_ms: int | None = None last_agent_message: str | None = None + def observe_assistant_message( + self, + message: PromptMessageExtended, + message_timestamp: datetime | None, + ) -> None: + if message_timestamp is not None: + self.completed_at = message_timestamp + + timing_payload = _message_timing_payload(message) + duration_ms = _milliseconds_field(timing_payload, "duration_ms") + if duration_ms is not None: + self.llm_duration_ms += duration_ms + + if self.time_to_first_token_ms is None: + self.time_to_first_token_ms = _message_time_to_first_token_ms(message) + + def duration_ms(self) -> int | None: + duration_ms = _elapsed_ms(self.started_at, self.completed_at) + if duration_ms is not None: + return duration_ms + return self.llm_duration_ms if self.llm_duration_ms > 0 else None + _PRIVACY_FILTER_LIMITATIONS = [ "file_paths_not_redacted", @@ -1003,7 +1330,10 @@ def start_turn(user_message: PromptMessageExtended | None) -> None: turn_timestamps[turn_counter] if turn_counter < len(turn_timestamps) else None ) turn_counter += 1 - current_turn = _TurnState(turn_id=f"turn-{turn_counter}") + current_turn = _TurnState( + turn_id=f"turn-{turn_counter}", + started_at=turn_timestamp, + ) records.append( _record( "event_msg", @@ -1039,14 +1369,19 @@ def finish_turn() -> None: nonlocal current_turn if current_turn is None: return + completed_at = current_turn.completed_at records.append( _record( "event_msg", _turn_complete_payload( current_turn.turn_id, current_turn.last_agent_message, + completed_at=completed_at, + duration_ms=current_turn.duration_ms(), + time_to_first_token_ms=current_turn.time_to_first_token_ms, sanitization=sanitization, ), + timestamp=completed_at, ) ) current_turn = None @@ -1061,6 +1396,7 @@ def finish_turn() -> None: start_turn(None) if current_turn is not None and message.role == "assistant": + current_turn.observe_assistant_message(message, message_timestamp) texts = _message_texts(message.content) if texts: current_turn.last_agent_message = texts[-1] diff --git a/src/fast_agent/skills/operations.py b/src/fast_agent/skills/operations.py index 193c28ece..3bbb17cef 100644 --- a/src/fast_agent/skills/operations.py +++ b/src/fast_agent/skills/operations.py @@ -14,7 +14,7 @@ import shutil import subprocess import tempfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, cast from fast_agent.marketplace import source_utils as marketplace_source_utils @@ -80,7 +80,7 @@ async def fetch_marketplace_skills(url: str) -> list[MarketplaceSkill]: async def fetch_marketplace_skills_with_source( url: str, ) -> tuple[list[MarketplaceSkill], str]: - return await marketplace_source_utils.fetch_marketplace_entries_with_source( + skills, resolved_source = await marketplace_source_utils.fetch_marketplace_entries_with_source( url, candidate_urls=candidate_marketplace_urls, normalize_url=normalize_marketplace_url, @@ -90,6 +90,7 @@ async def fetch_marketplace_skills_with_source( source_url=source_url, ), ) + return await asyncio.to_thread(_expand_implicit_skill_bundles, skills), resolved_source async def install_marketplace_skill( @@ -711,6 +712,88 @@ def _copy_skill_from_marketplace_source( return commit, path_oid, "remote" +def _expand_implicit_skill_bundles(skills: Sequence[MarketplaceSkill]) -> list[MarketplaceSkill]: + expanded: list[MarketplaceSkill] = [] + for skill in skills: + expanded.extend(_expand_implicit_skill_bundle(skill)) + return expanded + + +def _expand_implicit_skill_bundle(skill: MarketplaceSkill) -> list[MarketplaceSkill]: + if not _may_be_implicit_skill_bundle(skill): + return [skill] + + local_repo = _resolve_local_repo(skill.repo_url) + try: + if local_repo is not None: + source_dir = _resolve_repo_subdir(local_repo, skill.repo_subdir) + return _discover_nested_marketplace_skills(skill, source_dir) + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + clone_args = [ + "git", + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + ] + if skill.repo_ref: + clone_args.extend(["--branch", skill.repo_ref]) + clone_args.extend([skill.repo_url, str(tmp_path)]) + + _run_git(clone_args) + _run_git(["git", "-C", str(tmp_path), "sparse-checkout", "set", skill.repo_subdir]) + _run_git(["git", "-C", str(tmp_path), "checkout"]) + source_dir = _resolve_repo_subdir(tmp_path, skill.repo_subdir) + return _discover_nested_marketplace_skills(skill, source_dir) + except Exception: + return [skill] + + +def _may_be_implicit_skill_bundle(skill: MarketplaceSkill) -> bool: + path = PurePosixPath(skill.repo_subdir) + if path.name.lower() == "skill.md": + return False + return "skills" not in path.parts + + +def _discover_nested_marketplace_skills( + skill: MarketplaceSkill, + source_dir: Path, +) -> list[MarketplaceSkill]: + if (source_dir / "SKILL.md").exists() or ( + source_dir.is_file() and source_dir.name.lower() == "skill.md" + ): + return [skill] + + skills_dir = source_dir / "skills" + manifests = SkillRegistry.load_directory(skills_dir) + if not manifests: + return [skill] + + nested: list[MarketplaceSkill] = [] + for manifest in manifests: + relative_skill_dir = manifest.path.parent.relative_to(source_dir) + repo_path = PurePosixPath(skill.repo_subdir) / PurePosixPath( + relative_skill_dir.as_posix() + ) + nested.append( + MarketplaceSkill( + name=manifest.name, + description=manifest.description, + repo_url=skill.repo_url, + repo_ref=skill.repo_ref, + repo_path=str(repo_path), + source_url=skill.source_url, + bundle_name=skill.bundle_name or skill.name, + bundle_description=skill.bundle_description or skill.description, + ) + ) + return nested + + def _atomic_replace_directory(*, existing_dir: Path, staged_dir: Path) -> None: marketplace_source_utils.atomic_replace_directory( existing_dir=existing_dir, diff --git a/src/fast_agent/tools/shell_runtime.py b/src/fast_agent/tools/shell_runtime.py index 59237f812..f4e2c0fe0 100644 --- a/src/fast_agent/tools/shell_runtime.py +++ b/src/fast_agent/tools/shell_runtime.py @@ -27,6 +27,7 @@ ) from fast_agent.core.logging.progress_payloads import build_progress_payload from fast_agent.event_progress import ProgressAction +from fast_agent.home import build_child_environment from fast_agent.ui import console from fast_agent.ui.console_display import ConsoleDisplay from fast_agent.ui.display_suppression import display_tools_enabled @@ -35,10 +36,12 @@ SHELL_OUTPUT_TRUNCATION_MARKER, split_shell_output_line_limit, ) -from fast_agent.utils.async_utils import gather_with_cancel _STREAM_READ_CHUNK_SIZE = 4096 _MAX_PENDING_STREAM_BYTES = 65536 +_IO_DRAIN_TIMEOUT_SECONDS = 2.0 +_PROCESS_EXIT_POLL_SECONDS = 0.1 +_asyncio_sleep = asyncio.sleep @dataclass(frozen=True, slots=True) @@ -53,14 +56,16 @@ class _ShellProcessPlan: @dataclass(slots=True) class _ShellOutputState: output_segments: list[str] = field(default_factory=list) + output_tail_bytes: bytearray = field(default_factory=bytearray) output_bytes: int = 0 total_output_bytes: int = 0 output_truncated: bool = False truncation_notice_printed: bool = False had_stream_output: bool = False output_line_count: int = 0 - last_output_time: float = field(default_factory=time.time) + last_output_time: float = field(default_factory=time.monotonic) timeout_occurred: bool = False + io_drain_timed_out: bool = False @dataclass(slots=True) @@ -104,14 +109,17 @@ def __init__( self.enabled: bool = activation_reason is not None self._tool: Tool | None = None self._display = ConsoleDisplay(config=config) + self._config = config self._agent_name = agent_name self._output_display_lines: int | None = None self._show_bash_output = True + self._prefer_local_shell = False if config is not None: shell_config = getattr(config, "shell_execution", None) if shell_config is not None: self._output_display_lines = getattr(shell_config, "output_display_lines", None) self._show_bash_output = bool(getattr(shell_config, "show_bash", True)) + self._prefer_local_shell = shell_config.prefer_local_shell if self.enabled: # Detect the shell early so we can include it in the tool description @@ -138,6 +146,11 @@ def __init__( def tool(self) -> Tool | None: return self._tool + @property + def prefer_local_shell(self) -> bool: + """Whether ACP mode should keep this local shell runtime instead of client terminal.""" + return self._prefer_local_shell + @property def output_byte_limit(self) -> int: """Return the current byte limit used to retain command output.""" @@ -145,7 +158,7 @@ def output_byte_limit(self) -> int: @property def timeout_seconds(self) -> int: - """Return the timeout used for shell execution.""" + """Return the idle/no-output timeout used for shell execution.""" return self._timeout_seconds def set_output_byte_limit(self, output_byte_limit: int | None) -> None: @@ -286,6 +299,10 @@ def _build_process_plan(self, configured_working_dir: Path) -> _ShellProcessPlan "stdout": asyncio.subprocess.PIPE, "stderr": asyncio.subprocess.PIPE, "cwd": working_dir, + "env": build_child_environment( + active_home=getattr(self._config, "_fast_agent_home", None), + noenv=bool(getattr(self._config, "_fast_agent_noenv", False)), + ), } if is_windows: creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) @@ -348,6 +365,7 @@ def _build_display_state( def _append_output_text(self, output_text: str, state: _ShellOutputState) -> None: output_blob = output_text.encode("utf-8", errors="replace") state.total_output_bytes += len(output_blob) + self._append_output_tail(output_blob, state) if state.output_truncated: return @@ -366,6 +384,23 @@ def _append_output_text(self, output_text: str, state: _ShellOutputState) -> Non state.output_bytes += remaining state.output_truncated = True + def _append_output_tail(self, output_blob: bytes, state: _ShellOutputState) -> None: + tail_limit = self._truncated_tail_byte_limit() + if len(output_blob) >= tail_limit: + state.output_tail_bytes = bytearray(output_blob[-tail_limit:]) + return + + state.output_tail_bytes.extend(output_blob) + overflow = len(state.output_tail_bytes) - tail_limit + if overflow > 0: + del state.output_tail_bytes[:overflow] + + def _truncated_tail_byte_limit(self) -> int: + return max(self._output_byte_limit // 2, 1) + + def _truncated_head_byte_limit(self) -> int: + return max(self._output_byte_limit - self._truncated_tail_byte_limit(), 1) + def _maybe_print_truncation_notice( self, *, @@ -453,7 +488,7 @@ def _record_stream_output( style, display_state=display_state, ) - output_state.last_output_time = time.time() + output_state.last_output_time = time.monotonic() async def _stream_process_output( self, @@ -590,7 +625,7 @@ async def _watch_process_timeout( self._logger.debug("Watchdog: process exited normally") return - elapsed = time.time() - output_state.last_output_time + elapsed = time.monotonic() - output_state.last_output_time remaining = self._timeout_seconds - elapsed time_since_warning = elapsed - last_warning_time if time_since_warning >= self._warning_interval_seconds and remaining > 0: @@ -625,11 +660,42 @@ async def _cancel_task_if_running(self, task: asyncio.Task[None] | None) -> None except asyncio.CancelledError: return + async def _drain_output_tasks( + self, + tasks: list[asyncio.Task[None]], + *, + timeout_seconds: float, + ) -> bool: + done, pending = await asyncio.wait(tasks, timeout=timeout_seconds) + drain_timed_out = bool(pending) + try: + for task in done: + await task + finally: + for task in pending: + task.cancel() + for task in pending: + try: + await task + except asyncio.CancelledError: + pass + return drain_timed_out + async def _wait_for_process_exit( self, process: asyncio.subprocess.Process, *, is_windows: bool, + ) -> int: + while process.returncode is None: + await _asyncio_sleep(_PROCESS_EXIT_POLL_SECONDS) + return process.returncode + + async def _wait_for_process_exit_after_termination( + self, + process: asyncio.subprocess.Process, + *, + is_windows: bool, ) -> int: try: return await asyncio.wait_for(process.wait(), timeout=2.0) @@ -643,12 +709,32 @@ async def _wait_for_process_exit( except Exception: return -1 - def _truncation_summary(self, output_state: _ShellOutputState) -> str | None: + def _truncation_summary( + self, + output_state: _ShellOutputState, + *, + head_bytes: int | None = None, + tail_bytes: int | None = None, + ) -> str | None: if not output_state.output_truncated: return None - retained_tokens = max(int(output_state.output_bytes / TERMINAL_BYTES_PER_TOKEN), 1) + retained_bytes = ( + output_state.output_bytes + if head_bytes is None or tail_bytes is None + else head_bytes + tail_bytes + ) + retained_tokens = max(int(retained_bytes / TERMINAL_BYTES_PER_TOKEN), 1) total_tokens = max(int(output_state.total_output_bytes / TERMINAL_BYTES_PER_TOKEN), 1) - omitted_bytes = max(output_state.total_output_bytes - output_state.output_bytes, 0) + omitted_bytes = max(output_state.total_output_bytes - retained_bytes, 0) + if head_bytes is not None and tail_bytes is not None: + return ( + "[Output truncated: showing first " + f"{head_bytes} bytes and last {tail_bytes} bytes of " + f"{output_state.total_output_bytes} bytes " + f"(~{retained_tokens} of ~{total_tokens} tokens); " + f"omitted {omitted_bytes} middle bytes. " + "Increase shell_execution.output_byte_limit to retain more.]" + ) return ( "[Output truncated: retained " f"{output_state.output_bytes} of {output_state.total_output_bytes} bytes " @@ -657,19 +743,51 @@ def _truncation_summary(self, output_state: _ShellOutputState) -> str | None: "Increase shell_execution.output_byte_limit to retain more.]" ) + def _truncated_combined_output(self, output_state: _ShellOutputState) -> str: + head_limit = self._truncated_head_byte_limit() + head_blob = "".join(output_state.output_segments).encode("utf-8", errors="replace")[ + :head_limit + ] + tail_blob = bytes(output_state.output_tail_bytes)[-self._truncated_tail_byte_limit() :] + + parts: list[str] = [] + if head_blob: + head_text = head_blob.decode("utf-8", errors="replace") + parts.append(head_text if head_text.endswith("\n") else f"{head_text}\n") + + truncation_summary = self._truncation_summary( + output_state, + head_bytes=len(head_blob), + tail_bytes=len(tail_blob), + ) + if truncation_summary: + parts.append(f"{truncation_summary}\n") + + if tail_blob: + tail_text = tail_blob.decode("utf-8", errors="replace") + parts.append(tail_text if tail_text.endswith("\n") else f"{tail_text}\n") + + return "".join(parts) + def _build_shell_result( self, *, return_code: int, output_state: _ShellOutputState, ) -> tuple[CallToolResult, str]: - combined_output = "".join(output_state.output_segments) + combined_output = ( + self._truncated_combined_output(output_state) + if output_state.output_truncated + else "".join(output_state.output_segments) + ) if combined_output and not combined_output.endswith("\n"): combined_output += "\n" - truncation_summary = self._truncation_summary(output_state) - if truncation_summary: - combined_output += f"{truncation_summary}\n" + if output_state.io_drain_timed_out: + combined_output += ( + f"(output collection stopped after {_IO_DRAIN_TIMEOUT_SECONDS:.1f}s " + "because stdout/stderr pipes remained open)\n" + ) if output_state.timeout_occurred: combined_output += f"(timeout after {self._timeout_seconds}s - process terminated)" @@ -756,7 +874,7 @@ async def execute( "The execute tool requires a 'command' string argument." ) self._logger.debug( - f"Executing command with timeout={self._timeout_seconds}s, warning_interval={self._warning_interval_seconds}s" + f"Executing command with idle_timeout={self._timeout_seconds}s, warning_interval={self._warning_interval_seconds}s" ) configured_working_dir = self.working_directory() @@ -807,12 +925,15 @@ async def execute( ) ) - await gather_with_cancel([stdout_task, stderr_task]) - await self._cancel_task_if_running(watchdog_task) return_code = await self._wait_for_process_exit( process, is_windows=plan.is_windows, ) + await self._cancel_task_if_running(watchdog_task) + output_state.io_drain_timed_out = await self._drain_output_tasks( + [stdout_task, stderr_task], + timeout_seconds=_IO_DRAIN_TIMEOUT_SECONDS, + ) result, completion_details = self._build_shell_result( return_code=return_code, output_state=output_state, @@ -847,7 +968,7 @@ async def execute( ) return CallToolResult( isError=True, - content=[TextContent(type="text", text=f"Command failed to start: {exc}")], + content=[TextContent(type="text", text=f"Command execution failed: {exc}")], ) def _emit_progress_event( diff --git a/src/fast_agent/types/__init__.py b/src/fast_agent/types/__init__.py index c1c59fa25..18c9efe42 100644 --- a/src/fast_agent/types/__init__.py +++ b/src/fast_agent/types/__init__.py @@ -10,7 +10,12 @@ # Re-export ResourceLink from MCP for convenience from mcp.types import ResourceLink -from fast_agent.llm.request_params import RequestParams, ResponseMode, ToolResultMode +from fast_agent.llm.request_params import ( + RequestParams, + ResponseMode, + StructuredToolPolicy, + ToolResultMode, +) # Content helpers commonly used by users to build messages from fast_agent.mcp.helpers.content_helpers import ( @@ -52,6 +57,7 @@ "PromptMessageExtended", "RequestParams", "ResponseMode", + "StructuredToolPolicy", "ResourceLink", "ToolResultMode", # Content helpers diff --git a/src/fast_agent/ui/console_display.py b/src/fast_agent/ui/console_display.py index 4a6a78517..f1f9e54ff 100644 --- a/src/fast_agent/ui/console_display.py +++ b/src/fast_agent/ui/console_display.py @@ -939,8 +939,8 @@ def _extract_openai_phase_content( saw_phase = True phase_label = PHASE_LABELS.get(phase, phase) label = Text() - label.append("▎", style="dim") - label.append(phase_label, style="dim") + label.append("▎", style="green") + label.append(phase_label, style="green") if self._looks_like_markdown(section_text): sections.append( Group( diff --git a/src/fast_agent/ui/interactive/command_dispatch.py b/src/fast_agent/ui/interactive/command_dispatch.py index db7b33f0b..eaa865a5b 100644 --- a/src/fast_agent/ui/interactive/command_dispatch.py +++ b/src/fast_agent/ui/interactive/command_dispatch.py @@ -3,10 +3,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, cast from rich import print as rich_print +from fast_agent.command_actions import ( + PluginCommandActionContext, + PluginCommandActionRegistry, +) from fast_agent.commands.handlers import agent_cards as agent_card_handlers from fast_agent.commands.handlers import cards_manager as cards_handlers from fast_agent.commands.handlers import display as display_handlers @@ -23,6 +27,8 @@ from fast_agent.commands.results import CommandOutcome from fast_agent.commands.session_export_help import render_session_export_help_markdown from fast_agent.commands.shared_command_intents import should_default_export_agent +from fast_agent.core.exceptions import AgentConfigError +from fast_agent.core.logging.logger import get_logger from fast_agent.ui import enhanced_prompt from fast_agent.ui.command_payloads import ( AgentCommand, @@ -91,9 +97,12 @@ if TYPE_CHECKING: from pathlib import Path + from fast_agent.command_actions.models import PluginCommandAgentProtocol from fast_agent.core.agent_app import AgentApp from fast_agent.ui.interactive_prompt import InteractivePrompt +logger = get_logger(__name__) + @dataclass class DispatchResult: @@ -889,6 +898,18 @@ async def dispatch_command_payload( ) -> DispatchResult: del available_agents + plugin_result = await _dispatch_plugin_command_payload( + owner, + payload, + prompt_provider=prompt_provider, + agent=agent, + available_agents_set=available_agents_set, + merge_pinned_agents=merge_pinned_agents, + shell_working_dir=shell_working_dir, + ) + if plugin_result is not None: + return plugin_result + local_result = await _dispatch_local_ui_payload( payload, prompt_provider=prompt_provider, @@ -978,3 +999,106 @@ async def dispatch_command_payload( return reload_result return DispatchResult(handled=False) + + +async def _dispatch_plugin_command_payload( + owner: "InteractivePrompt", + payload: CommandPayload, + *, + prompt_provider: "AgentApp", + agent: str, + available_agents_set: set[str], + merge_pinned_agents: Callable[[list[str]], list[str]], + shell_working_dir: Path | None, +) -> DispatchResult | None: + if not isinstance(payload, UnknownCommand): + return None + + command_line = payload.command.strip() + if not command_line.startswith("/"): + return None + + command_name, _, arguments = command_line[1:].partition(" ") + command_name = command_name.strip() + arguments = arguments.lstrip() + if not command_name: + return None + + current_agent = prompt_provider.get_agent(agent) + if current_agent is None: + return None + + spec = None + base_path = None + agent_commands = current_agent.config.commands + if agent_commands is not None: + spec = agent_commands.get(command_name) + if spec is not None and current_agent.config.source_path is not None: + base_path = current_agent.config.source_path.parent + + if spec is None and prompt_provider.plugin_commands is not None: + spec = prompt_provider.plugin_commands.get(command_name) + base_path = prompt_provider.plugin_command_base_path + + if spec is None: + return None + + try: + registry = PluginCommandActionRegistry.from_specs( + {command_name: spec}, + base_path=base_path, + ) + context = build_command_context(prompt_provider, agent) + plugin_context = PluginCommandActionContext( + command_name=command_name, + arguments=arguments, + agent=cast("PluginCommandAgentProtocol", current_agent), + settings=context.settings, + session_cwd=shell_working_dir, + ) + action_result = await registry.execute(command_name, plugin_context) + except AgentConfigError as exc: + logger.warning("Failed to load plugin command action", command=command_name, error=str(exc)) + rich_print(f"[red]Command /{command_name} failed to load:[/red] {exc}") + return DispatchResult(handled=True) + except Exception as exc: # noqa: BLE001 + logger.exception("Plugin command action failed", command=command_name) + rich_print(f"[red]Command /{command_name} failed:[/red] {exc}") + return DispatchResult(handled=True) + + if action_result is None: + return DispatchResult(handled=True) + + outcome = CommandOutcome( + buffer_prefill=action_result.buffer_prefill, + switch_agent=action_result.switch_agent, + requires_refresh=action_result.refresh_agents, + ) + if action_result.markdown: + outcome.add_message(action_result.markdown, render_markdown=True) + elif action_result.message: + outcome.add_message(action_result.message) + + await emit_command_outcome(context, outcome) + + result = DispatchResult( + handled=True, + buffer_prefill=outcome.buffer_prefill, + next_agent=outcome.switch_agent, + ) + + if outcome.requires_refresh: + next_available_agents, next_available_agents_set = _refresh_available_agents( + owner, + prompt_provider, + merge_pinned_agents, + ) + result.available_agents = next_available_agents + result.available_agents_set = next_available_agents_set + available_agents_set = next_available_agents_set + + if result.next_agent is not None and result.next_agent not in available_agents_set: + rich_print(f"[red]Unknown agent:[/red] {result.next_agent}") + result.next_agent = None + + return result diff --git a/src/fast_agent/ui/mcp_display.py b/src/fast_agent/ui/mcp_display.py index c11f3a41f..2dab006a8 100644 --- a/src/fast_agent/ui/mcp_display.py +++ b/src/fast_agent/ui/mcp_display.py @@ -393,7 +393,7 @@ def _format_capability_shorthand( ("Co", bool(completion_caps), False), ("Ex", bool(experimental_caps), False), ("In", _instruction_capability_state(status, template_expected=template_expected), False), - ("Sk", _skybridge_capability_state(status), False), + ("Ui", _skybridge_capability_state(status), False), ("Ro", bool(status.roots_configured), False), ("El", _elicitation_capability_state(status.elicitation_mode), False), ("Sa", _sampling_capability_state(status.sampling_mode), False), diff --git a/src/fast_agent/ui/model_picker_common.py b/src/fast_agent/ui/model_picker_common.py index cd3fda4b7..aeb3d82bd 100644 --- a/src/fast_agent/ui/model_picker_common.py +++ b/src/fast_agent/ui/model_picker_common.py @@ -157,6 +157,7 @@ def _provider_is_active(provider: Provider, config_payload: dict[str, Any]) -> b return False + def _catalog_options_from_entries( entries: tuple[CatalogModelEntry, ...], *, diff --git a/src/fast_agent/ui/prompt/completer.py b/src/fast_agent/ui/prompt/completer.py index 92f3a0767..db243ccee 100644 --- a/src/fast_agent/ui/prompt/completer.py +++ b/src/fast_agent/ui/prompt/completer.py @@ -17,6 +17,11 @@ from prompt_toolkit.completion import Completer, Completion from fast_agent.agents.agent_types import AgentType +from fast_agent.command_actions.accessors import ( + lookup_agent, + plugin_commands_for_agent, + plugin_commands_for_provider, +) from fast_agent.commands.handlers import history as history_handlers from fast_agent.commands.handlers import model as model_handlers from fast_agent.config import get_settings @@ -127,6 +132,7 @@ def __init__( self.commands.pop("prompt", None) # Remove prompt command in human input mode self.commands.pop("tools", None) # Remove tools command in human input mode self.commands.pop("usage", None) # Remove usage command in human input mode + self._add_plugin_commands() self.agent_types = agent_types or {} self._mention_cache: dict[tuple[Any, ...], AgentCompleter._CacheEntry] = {} self._mention_cache_ttl_seconds = 3.0 @@ -136,6 +142,30 @@ def __init__( except RuntimeError: self._owner_loop = None + def _add_plugin_commands(self) -> None: + if self.agent_provider is None or self.current_agent is None: + return + + commands = {} + global_commands = plugin_commands_for_provider(self.agent_provider) + if global_commands: + commands.update(global_commands) + + agent = lookup_agent(self.agent_provider, self.current_agent) + agent_commands = plugin_commands_for_agent(agent) + if agent_commands: + commands.update(agent_commands) + + for name, spec in commands.items(): + if name in self.commands: + continue + description = spec.description + if spec.input_hint: + description = f"{description} {spec.input_hint}" + if spec.key: + description = f"{description} (key: {spec.key})" + self.commands[name] = description + def _current_agent_has_web_tools_enabled(self) -> bool: return history_handlers.web_tools_enabled_for_agent(self._current_llm_agent()) diff --git a/src/fast_agent/ui/prompt/input.py b/src/fast_agent/ui/prompt/input.py index 25a3e82ea..6e45859bc 100644 --- a/src/fast_agent/ui/prompt/input.py +++ b/src/fast_agent/ui/prompt/input.py @@ -437,6 +437,80 @@ def _collect_tool_children(agent: object) -> list[Any]: return _collect_tool_children_impl(agent) +def _format_count(count: int, singular: str, plural: str | None = None) -> str: + label = singular if count == 1 else (plural or f"{singular}s") + return f"{count:,} {label}" + + +def _display_path(path: str) -> str: + home = Path.home() + resolved = Path(path).expanduser() + try: + return f"~/{resolved.resolve().relative_to(home).as_posix()}" + except ValueError: + return str(resolved) + + +def _count_configured_hooks(agent_provider: "AgentApp") -> int: + total = 0 + for agent in agent_provider.registered_agents().values(): + config = getattr(agent, "config", None) + for field in ("tool_hooks", "lifecycle_hooks"): + hooks = getattr(config, field, None) + if isinstance(hooks, dict): + total += len(hooks) + return total + + +def _count_configured_extensions(agent_provider: "AgentApp") -> int: + total = 0 + global_commands = getattr(agent_provider, "plugin_commands", None) + if isinstance(global_commands, dict): + total += len(global_commands) + + for agent in agent_provider.registered_agents().values(): + config = getattr(agent, "config", None) + commands = getattr(config, "commands", None) + if isinstance(commands, dict): + total += len(commands) + + return total + + +def _show_fast_agent_home_summary(agent_provider: "AgentApp | None") -> None: + if agent_provider is None: + return + try: + first_agent = next(iter(agent_provider.registered_agents().values())) + except StopIteration: + return + + context = getattr(first_agent, "context", None) + config = getattr(context, "config", None) + home = getattr(config, "_fast_agent_home", None) + if not home: + return + + model_refs = getattr(config, "model_references", None) + model_ref_count = ( + sum(len(namespace_refs) for namespace_refs in model_refs.values()) + if isinstance(model_refs, dict) + else 0 + ) + parts = [ + _format_count(len(agent_provider.registered_agent_names()), "agent"), + _format_count(_count_configured_hooks(agent_provider), "hook"), + _format_count(_count_configured_extensions(agent_provider), "extension"), + _format_count(model_ref_count, "modelref"), + ] + source = getattr(config, "_fast_agent_home_source", None) + source_suffix = f" [dim]via {source}[/dim]" if source else "" + rich_print( + f"[dim]fast-agent environment[/dim] [blue]{_display_path(str(home))}[/blue]" + f"[dim] ({', '.join(parts)}){source_suffix}[/dim]" + ) + + # AgentCompleter moved to fast_agent.ui.prompt.completer @@ -909,6 +983,8 @@ async def _show_input_startup( _show_input_help_banner(is_human_input=is_human_input) _show_model_shortcut_hints(agent_name=agent_name, agent_provider=agent_provider) + if agent_provider and not is_human_input: + _show_fast_agent_home_summary(agent_provider) await _show_shell_startup( agent_name=agent_name, agent_provider=agent_provider, diff --git a/src/fast_agent/ui/prompt/keybindings.py b/src/fast_agent/ui/prompt/keybindings.py index 6473da850..f8a1de5e1 100644 --- a/src/fast_agent/ui/prompt/keybindings.py +++ b/src/fast_agent/ui/prompt/keybindings.py @@ -11,6 +11,12 @@ from prompt_toolkit.lexers import Lexer from rich import print as rich_print +from fast_agent.command_actions.accessors import ( + lookup_agent, + plugin_commands_for_agent, + plugin_commands_for_provider, +) +from fast_agent.core.logging.logger import get_logger from fast_agent.ui.prompt.attachment_tokens import strip_local_attachment_tokens from fast_agent.ui.prompt.editor import get_text_from_editor from fast_agent.ui.prompt.parser import try_parse_hash_agent_command @@ -52,6 +58,9 @@ class PromptInputInterrupt(Exception): """Internal prompt-toolkit interrupt used instead of raw KeyboardInterrupt.""" +logger = get_logger(__name__) + + def _cycle_completion(buffer: Buffer, *, backwards: bool) -> bool: """Cycle through current completion menu items. @@ -333,4 +342,50 @@ def _show_copy_notice() -> None: except Exception: pass + _add_plugin_command_keybindings(kb, agent_provider=agent_provider, agent_name=agent_name) + return kb + + +def _add_plugin_command_keybindings( + kb: AgentKeyBindings, + *, + agent_provider: "AgentApp | None", + agent_name: str | None, +) -> None: + if agent_provider is None or agent_name is None: + return + + commands = {} + global_commands = plugin_commands_for_provider(agent_provider) + if global_commands: + commands.update(global_commands) + agent = lookup_agent(agent_provider, agent_name) + agent_commands = plugin_commands_for_agent(agent) + if agent_commands: + commands.update(agent_commands) + + for command_name, spec in commands.items(): + if not spec.key: + continue + keys = tuple(part for part in spec.key.split() if part) + if not keys: + continue + + try: + + @kb.add(*keys) + def _(event, command_name=command_name) -> None: + command = f"/{command_name}" + event.current_buffer.text = command + event.current_buffer.cursor_position = len(command) + event.current_buffer.validate_and_handle() + + except Exception as exc: # noqa: BLE001 + logger.warning( + "Ignoring invalid plugin command keybinding", + agent=agent_name, + command=command_name, + key=spec.key, + error=str(exc), + ) diff --git a/src/fast_agent/ui/rich_progress.py b/src/fast_agent/ui/rich_progress.py index 1ba01fd53..dedffe5c9 100644 --- a/src/fast_agent/ui/rich_progress.py +++ b/src/fast_agent/ui/rich_progress.py @@ -40,7 +40,7 @@ def __init__( super().__init__(table_column=table_column or Column(no_wrap=True)) def render(self, task: "Task") -> Text: - description_markup = f"[{self.description_style}]{task.description}▎" + description_markup = f"[{self.description_style}]{task.description}" if self.markup: description_text = Text.from_markup(description_markup) else: @@ -64,12 +64,12 @@ def __init__(self, console: Console | None = None) -> None: self._lock = RLock() self._taskmap: dict[str, TaskID] = {} self._task_kind: dict[str, str] = {} - self._description_spinner = SpinnerDescriptionColumn(spinner_name="dots3") + self._description_spinner = SpinnerDescriptionColumn(spinner_name="dots11") self._progress = Progress( self._description_spinner, TextColumn( text_format="{task.fields[target]}", - style="Bold Blue", + style="Blue", table_column=Column( min_width=10, max_width=16, @@ -94,7 +94,9 @@ def __init__(self, console: Console | None = None) -> None: self._stopped = False self._deferred_resume_at: float | None = None trace_path_raw = os.getenv("FAST_AGENT_PROGRESS_DEBUG_TRACE", "").strip() - self._trace_path: Path | None = Path(trace_path_raw).expanduser() if trace_path_raw else None + self._trace_path: Path | None = ( + Path(trace_path_raw).expanduser() if trace_path_raw else None + ) def _live_started(self) -> bool: """Return whether Rich's Live renderer has been started.""" @@ -297,14 +299,14 @@ def _get_action_style(self, action: ProgressAction) -> str: ProgressAction.CONNECTING: "bold yellow", ProgressAction.LOADED: "dim green", ProgressAction.INITIALIZED: "dim green", - ProgressAction.CHATTING: "bold blue", - ProgressAction.STREAMING: "bold green", # Assistant Colour - ProgressAction.THINKING: "bold yellow", # Assistant Colour - ProgressAction.ROUTING: "bold blue", - ProgressAction.PLANNING: "bold blue", + ProgressAction.SENDING: "blue", + ProgressAction.STREAMING: "green", # Assistant Colour + ProgressAction.THINKING: "yellow", # Assistant Colour + ProgressAction.ROUTING: "blue", + ProgressAction.PLANNING: "blue", ProgressAction.READY: "dim green", - ProgressAction.CALLING_TOOL: "bold magenta", - ProgressAction.TOOL_PROGRESS: "bold magenta", + ProgressAction.CALLING_TOOL: "magenta", + ProgressAction.TOOL_PROGRESS: "magenta", ProgressAction.FINISHED: "black on green", ProgressAction.SHUTDOWN: "black on red", ProgressAction.AGGREGATOR_INITIALIZED: "bold green", @@ -491,26 +493,20 @@ def _apply_update_locked(self, event: ProgressEvent) -> None: and event.correlation_id is not None and not self._is_internal_shell_tool(event.tool_name, event.server_name) ) - if ( - is_correlated_tool_event - and event.correlation_id - ): + if is_correlated_tool_event and event.correlation_id: task_name = f"{task_name}::{event.correlation_id}" - should_drop_tool_task = ( - is_correlated_tool_event - and ( - ( - event.action == ProgressAction.CALLING_TOOL - and self._is_terminal_tool_event(event.tool_event) - ) - or ( - event.action == ProgressAction.TOOL_PROGRESS - and self._is_terminal_tool_progress( - progress=event.progress, - total=event.total, - details=event.details, - ) + should_drop_tool_task = is_correlated_tool_event and ( + ( + event.action == ProgressAction.CALLING_TOOL + and self._is_terminal_tool_event(event.tool_event) + ) + or ( + event.action == ProgressAction.TOOL_PROGRESS + and self._is_terminal_tool_progress( + progress=event.progress, + total=event.total, + details=event.details, ) ) ) @@ -536,16 +532,13 @@ def _apply_update_locked(self, event: ProgressEvent) -> None: # Ensure no None values in the update # For streaming, use custom description immediately to avoid flashing if ( - event.action == ProgressAction.STREAMING - or event.action == ProgressAction.THINKING + event.action == ProgressAction.STREAMING or event.action == ProgressAction.THINKING ) and event.streaming_tokens: # Account for [dim][/dim] tags (11 characters) in padding calculation - formatted_tokens = ( - f"▎[dim]◀[/dim] {event.streaming_tokens.strip()}".ljust(17 + 11) - ) + formatted_tokens = f"▎[dim]◀[/dim] {event.streaming_tokens.strip()}".ljust(17 + 11) description = f"[{self._get_action_style(event.action)}]{formatted_tokens}" - elif event.action == ProgressAction.CHATTING: - # Add special formatting for chatting with dimmed arrow + elif event.action == ProgressAction.SENDING: + # Add special formatting for sending with dimmed arrow formatted_text = f"▎[dim]▶[/dim] {event.action.value.strip()}".ljust(17 + 11) description = f"[{self._get_action_style(event.action)}]{formatted_text}" elif event.action == ProgressAction.CALLING_TOOL: @@ -606,7 +599,9 @@ def _apply_update_locked(self, event: ProgressEvent) -> None: # This prevents idle/inactive agents from permanently cluttering the board. self._drop_task(task_name, task_id) elif event.action == ProgressAction.FINISHED: - finished_task = next((task for task in self._progress.tasks if task.id == task_id), None) + finished_task = next( + (task for task in self._progress.tasks if task.id == task_id), None + ) elapsed = finished_task.elapsed if finished_task is not None else None elapsed_str = time.strftime( "%H:%M:%S", time.gmtime(elapsed if elapsed is not None else 0) diff --git a/src/fast_agent/ui/streaming.py b/src/fast_agent/ui/streaming.py index 79039338d..e30d53064 100644 --- a/src/fast_agent/ui/streaming.py +++ b/src/fast_agent/ui/streaming.py @@ -40,7 +40,7 @@ logger = get_logger(__name__) -MARKDOWN_STREAM_TARGET_RATIO = 0.93 +MARKDOWN_STREAM_TARGET_RATIO = 1.0 MARKDOWN_STREAM_REFRESH_PER_SECOND = 16 MARKDOWN_STREAM_PRE_SCROLL_THROTTLE_RATIO = 0.7 STREAM_RENDER_WIDTH_GUTTER = 1 diff --git a/src/fast_agent/ui/tool_display.py b/src/fast_agent/ui/tool_display.py index 1b4ae83c7..d9a07cfb3 100644 --- a/src/fast_agent/ui/tool_display.py +++ b/src/fast_agent/ui/tool_display.py @@ -808,7 +808,7 @@ def _render_skybridge_structured_content( ) -> None: total_width = console.console.size.width resource_label = ( - f"skybridge resource: {resource_uri}" if resource_uri else "skybridge resource" + f"app resource: {resource_uri}" if resource_uri else "app resource" ) resource_text = Text(resource_label, style="magenta") line = self._display.style.metadata_line(resource_text, total_width) @@ -1225,7 +1225,17 @@ def add_warning(message: str) -> None: if not has_skybridge_signal: continue - valid_resource_count = sum(1 for resource in resources if resource.is_skybridge) + valid_resource_count = sum( + 1 for resource in resources if resource.is_valid_app_resource + ) + mcp_app_resource_count = sum(1 for resource in resources if resource.is_mcp_app) + skybridge_resource_count = sum(1 for resource in resources if resource.is_skybridge) + mcp_app_tool_count = sum( + 1 for tool in config.tools if tool.is_valid and tool.kind == "mcp_app" + ) + skybridge_tool_count = sum( + 1 for tool in config.tools if tool.is_valid and tool.kind == "skybridge" + ) server_rows.append( { @@ -1233,11 +1243,16 @@ def add_warning(message: str) -> None: "config": config, "resources": resources, "valid_resource_count": valid_resource_count, + "mcp_app_resource_count": mcp_app_resource_count, + "skybridge_resource_count": skybridge_resource_count, + "mcp_app_tool_count": mcp_app_tool_count, + "skybridge_tool_count": skybridge_tool_count, "total_resource_count": len(resources), "active_tools": [ { "name": tool.display_name, "template": str(tool.template_uri) if tool.template_uri else None, + "kind": tool.kind, } for tool in config.tools if tool.is_valid @@ -1267,7 +1282,7 @@ def show_skybridge_summary( if not server_rows and not warnings: return - heading = "[dim]OpenAI Apps SDK ([/dim][cyan]skybridge[/cyan][dim]) detected:[/dim]" + heading = "[dim]Interactive MCP app integrations detected:[/dim]" console.console.print() console.console.print(heading, markup=self._markup) @@ -1276,23 +1291,33 @@ def show_skybridge_summary( else: for row in server_rows: server_name = row["server_name"] - resource_count = row["valid_resource_count"] tool_infos = row["active_tools"] enabled = row["enabled"] - tool_count = len(tool_infos) - tool_word = "tool" if tool_count == 1 else "tools" - resource_word = ( - "skybridge resource" if resource_count == 1 else "skybridge resources" + segments: list[str] = [] + if row["mcp_app_tool_count"] or row["mcp_app_resource_count"]: + segments.append( + "[cyan]MCP Apps[/cyan][dim]: " + f"{row['mcp_app_tool_count']} tools, " + f"{row['mcp_app_resource_count']} resources[/dim]" + ) + if row["skybridge_tool_count"] or row["skybridge_resource_count"]: + segments.append( + "[cyan]OpenAI Apps SDK[/cyan][dim]: " + f"{row['skybridge_tool_count']} tools, " + f"{row['skybridge_resource_count']} resources[/dim]" + ) + integration_segment = ( + "[dim]; [/dim]".join(segments) + if segments + else "[dim]no active app integrations[/dim]" ) - tool_segment = f"[cyan]{tool_count}[/cyan][dim] {tool_word}[/dim]" - resource_segment = f"[cyan]{resource_count}[/cyan][dim] {resource_word}[/dim]" name_style = "cyan" if enabled else "yellow" status_suffix = "" if enabled else "[dim] (issues detected)[/dim]" console.console.print( f"[dim] ● [/dim][{name_style}]{server_name}[/{name_style}]{status_suffix}" - f"[dim] — [/dim]{tool_segment}[dim], [/dim]{resource_segment}", + f"[dim] — [/dim]{integration_segment}", markup=self._markup, ) diff --git a/tests/e2e/history/test_history_save_load_e2e.py b/tests/e2e/history/test_history_save_load_e2e.py index daecf8a27..466d90a30 100644 --- a/tests/e2e/history/test_history_save_load_e2e.py +++ b/tests/e2e/history/test_history_save_load_e2e.py @@ -23,10 +23,9 @@ "gemini25", "minimax", "kimi", - "qwen3", "glm", ] -DEFAULT_CHECK_MODELS = ["haiku", "gemini25", "gpt-5-mini?reasoning=minimal", "kimi", "qwen3", "glm"] +DEFAULT_CHECK_MODELS = ["haiku", "gemini25", "gpt-5-mini?reasoning=minimal", "kimi", "glm"] MAGIC_STRING = "MAGIC-ACCESS-PHRASE-9F1C" MAGIC_TOOL = Tool( name="fetch_magic_string", diff --git a/tests/e2e/llm/fastagent.config.yaml b/tests/e2e/llm/fastagent.config.yaml index 395359f98..2f3ba9e93 100644 --- a/tests/e2e/llm/fastagent.config.yaml +++ b/tests/e2e/llm/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/e2e/llm/test_hf_inference_lookup.py b/tests/e2e/llm/test_hf_inference_lookup.py index 5efb66955..4221d1ec7 100644 --- a/tests/e2e/llm/test_hf_inference_lookup.py +++ b/tests/e2e/llm/test_hf_inference_lookup.py @@ -16,18 +16,18 @@ @pytest.mark.e2e @pytest.mark.asyncio async def test_lookup_model_with_providers(): - """Test looking up a model that has inference providers (Kimi-K2-Thinking).""" - result = await lookup_inference_providers("moonshotai/Kimi-K2-Thinking") + """Test looking up a model that has inference providers (DeepSeek-V4-Pro).""" + result = await lookup_inference_providers("deepseek-ai/DeepSeek-V4-Pro") assert result.exists is True assert result.error is None - assert result.model_id == "moonshotai/Kimi-K2-Thinking" + assert result.model_id == "deepseek-ai/DeepSeek-V4-Pro" assert result.has_providers is True assert len(result.live_providers) > 0 # Verify at least one known provider exists provider_names = [p.name for p in result.live_providers] - known_providers = {"novita", "nebius", "together", "featherless-ai"} + known_providers = {"fireworks-ai", "novita", "nebius", "together", "featherless-ai"} assert any(name in known_providers for name in provider_names), ( f"Expected at least one known provider, got: {provider_names}" ) @@ -68,9 +68,9 @@ async def test_lookup_nonexistent_model(): @pytest.mark.asyncio async def test_lookup_strips_hf_prefix(): """Test that hf. prefix is correctly stripped from model ID.""" - result = await lookup_inference_providers("hf.moonshotai/Kimi-K2-Thinking") + result = await lookup_inference_providers("hf.deepseek-ai/DeepSeek-V4-Pro") - assert result.model_id == "moonshotai/Kimi-K2-Thinking" + assert result.model_id == "deepseek-ai/DeepSeek-V4-Pro" assert result.exists is True assert result.has_providers is True @@ -79,9 +79,9 @@ async def test_lookup_strips_hf_prefix(): @pytest.mark.asyncio async def test_lookup_strips_provider_suffix(): """Test that :provider suffix is correctly stripped from model ID.""" - result = await lookup_inference_providers("moonshotai/Kimi-K2-Thinking:together") + result = await lookup_inference_providers("deepseek-ai/DeepSeek-V4-Pro:together") - assert result.model_id == "moonshotai/Kimi-K2-Thinking" + assert result.model_id == "deepseek-ai/DeepSeek-V4-Pro" assert result.exists is True assert result.has_providers is True @@ -90,9 +90,9 @@ async def test_lookup_strips_provider_suffix(): @pytest.mark.asyncio async def test_lookup_strips_both_prefix_and_suffix(): """Test that both hf. prefix and :provider suffix are correctly stripped.""" - result = await lookup_inference_providers("hf.moonshotai/Kimi-K2-Thinking:novita") + result = await lookup_inference_providers("hf.deepseek-ai/DeepSeek-V4-Pro:novita") - assert result.model_id == "moonshotai/Kimi-K2-Thinking" + assert result.model_id == "deepseek-ai/DeepSeek-V4-Pro" assert result.exists is True assert result.has_providers is True @@ -101,14 +101,14 @@ async def test_lookup_strips_both_prefix_and_suffix(): @pytest.mark.asyncio async def test_format_model_strings(): """Test that format_model_strings returns correct model:provider strings.""" - result = await lookup_inference_providers("moonshotai/Kimi-K2-Thinking") + result = await lookup_inference_providers("deepseek-ai/DeepSeek-V4-Pro") assert result.has_providers model_strings = result.format_model_strings() assert len(model_strings) == len(result.live_providers) for model_str in model_strings: - assert model_str.startswith("moonshotai/Kimi-K2-Thinking:") + assert model_str.startswith("deepseek-ai/DeepSeek-V4-Pro:") provider_name = model_str.split(":")[-1] assert any(p.name == provider_name for p in result.live_providers) @@ -117,7 +117,7 @@ async def test_format_model_strings(): @pytest.mark.asyncio async def test_format_provider_list(): """Test that format_provider_list returns comma-separated provider names.""" - result = await lookup_inference_providers("moonshotai/Kimi-K2-Thinking") + result = await lookup_inference_providers("deepseek-ai/DeepSeek-V4-Pro") assert result.has_providers provider_list = result.format_provider_list() @@ -131,10 +131,10 @@ async def test_format_provider_list(): @pytest.mark.asyncio async def test_format_inference_lookup_message_with_providers(): """Test formatting a lookup result with providers.""" - result = await lookup_inference_providers("moonshotai/Kimi-K2-Thinking") + result = await lookup_inference_providers("deepseek-ai/DeepSeek-V4-Pro") message = format_inference_lookup_message(result) - assert "moonshotai/Kimi-K2-Thinking" in message + assert "deepseek-ai/DeepSeek-V4-Pro" in message assert "inference provider" in message.lower() assert "/set-model" in message assert "hf." in message diff --git a/tests/e2e/llm/test_llm_e2e.py b/tests/e2e/llm/test_llm_e2e.py index 620126f8c..a206991ad 100644 --- a/tests/e2e/llm/test_llm_e2e.py +++ b/tests/e2e/llm/test_llm_e2e.py @@ -55,7 +55,6 @@ def get_test_models(): # "gpt-oss", # "minimax", # "kimigroq", - # "kimithink", # "kimi", # "glm", # "qwen3:together", diff --git a/tests/e2e/llm/test_llm_e2e_reasoning.py b/tests/e2e/llm/test_llm_e2e_reasoning.py index c08bc177b..d9ad05cdd 100644 --- a/tests/e2e/llm/test_llm_e2e_reasoning.py +++ b/tests/e2e/llm/test_llm_e2e_reasoning.py @@ -13,9 +13,9 @@ from fast_agent.types.llm_stop_reason import LlmStopReason TEST_MODELS = [ - "hf.moonshotai/Kimi-K2-Thinking:novita", - "hf.moonshotai/Kimi-K2-Thinking:nebius", - "hf.moonshotai/Kimi-K2-Thinking:together", + "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", + "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", + "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", "minimax", ] diff --git a/tests/e2e/llm/test_llm_stream_diagnostics.py b/tests/e2e/llm/test_llm_stream_diagnostics.py index 29c8c7223..3c6ab6c6d 100644 --- a/tests/e2e/llm/test_llm_stream_diagnostics.py +++ b/tests/e2e/llm/test_llm_stream_diagnostics.py @@ -15,8 +15,8 @@ from fast_agent.types.llm_stop_reason import LlmStopReason TEST_MODELS = [ - "hf.moonshotai/Kimi-K2-Thinking:nebius", - "hf.moonshotai/Kimi-K2-Thinking:novita", + "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", + "hf.deepseek-ai/DeepSeek-V4-Pro:fireworks-ai", ] diff --git a/tests/e2e/multimodal/fastagent.config.yaml b/tests/e2e/multimodal/fastagent.config.yaml index 6ff1ba4a1..3069d83e4 100644 --- a/tests/e2e/multimodal/fastagent.config.yaml +++ b/tests/e2e/multimodal/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/e2e/multimodal/test_multimodal_images.py b/tests/e2e/multimodal/test_multimodal_images.py index f641364a9..3a6cc7c61 100644 --- a/tests/e2e/multimodal/test_multimodal_images.py +++ b/tests/e2e/multimodal/test_multimodal_images.py @@ -126,7 +126,7 @@ async def agent_function(): "model_name", [ "gpt-4.1-mini", # OpenAI model - "haiku35", # Anthropic model + "haiku", # Anthropic model "gpt-4o", # "gemini25", # This currently uses the OpenAI format. Google Gemini cannot process PDFs with the OpenAI format. It can only do so with the native Gemini format. ], @@ -160,7 +160,7 @@ async def agent_function(): "model_name", [ "gpt-4.1-mini", # OpenAI model - "haiku35", # Anthropic model + "haiku", # Anthropic model "gemini25", # This currently uses the OpenAI format. Google Gemini cannot process PDFs with the OpenAI format. It can only do so with the native Gemini format. "gpt-5-mini", ], diff --git a/tests/e2e/prompts-resources/fastagent.config.yaml b/tests/e2e/prompts-resources/fastagent.config.yaml index dff009c72..ec594a93b 100644 --- a/tests/e2e/prompts-resources/fastagent.config.yaml +++ b/tests/e2e/prompts-resources/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/e2e/prompts-resources/test_prompts.py b/tests/e2e/prompts-resources/test_prompts.py index 49c61cb34..014b15549 100644 --- a/tests/e2e/prompts-resources/test_prompts.py +++ b/tests/e2e/prompts-resources/test_prompts.py @@ -8,7 +8,7 @@ "model_name", [ "gpt-4.1-mini", # OpenAI model - "haiku35", # Anthropic model + "haiku", # Anthropic model "gemini25", # Google Gemini model -> Works. DONE. ], ) @@ -39,7 +39,7 @@ async def agent_function(): "model_name", [ "gpt-4.1-mini", # OpenAI model - "haiku35", # Anthropic model + "haiku", # Anthropic model # "gemini25", # Google Gemini model -> This involves opening a PDF. It is not supported by Google Gemini with the OpenAI format. Unless the format is changed to the native Gemini format, this will not work. ], ) @@ -70,7 +70,7 @@ async def agent_function(): "model_name", [ "gpt-4.1-mini", # OpenAI model - "haiku35", # Anthropic model + "haiku", # Anthropic model "gemini25", # Google Gemini model -> Works. DONE. ], ) diff --git a/tests/e2e/smoke/base/fastagent.config.yaml b/tests/e2e/smoke/base/fastagent.config.yaml index 2bf62bf4b..1d6a1dbe6 100644 --- a/tests/e2e/smoke/base/fastagent.config.yaml +++ b/tests/e2e/smoke/base/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/e2e/smoke/base/test_e2e_smoke.py b/tests/e2e/smoke/base/test_e2e_smoke.py index d73ac326d..7f6d6f73c 100644 --- a/tests/e2e/smoke/base/test_e2e_smoke.py +++ b/tests/e2e/smoke/base/test_e2e_smoke.py @@ -21,12 +21,12 @@ "gpt-4.1-mini", "gpt-4o-mini", # OpenAI model "o3-mini?reasoning=low", # reasoner - "haiku35", # Anthropic model + "haiku", # Anthropic model "deepseek", "generic.qwen2.5:latest", "generic.llama3.2:latest", "openrouter.google/gemini-2.0-flash-001", - "googleoai.gemini-2.5-flash-preview-05-20", + "googleoai.gemini-2.5-flash", "google.gemini-2.0-flash", "gemini2", "gemini25", # Works -> Done. Works most of the time, unless Gemini decides to write very long outputs. @@ -96,7 +96,7 @@ async def agent_function(): "model_name", [ "gpt-4o-mini", # OpenAI model - "haiku35", # Anthropic model + "haiku", # Anthropic model "sonnet", # Anthropic model "deepseek", "openrouter.google/gemini-2.0-flash-001", @@ -304,7 +304,7 @@ async def agent_function(): "model_name", [ "deepseek", - "haiku35", + "haiku", "gpt-4o", "gpt-4.1-nano", "gpt-4.1-mini", @@ -364,7 +364,7 @@ async def weather_forecast(): "model_name", [ "deepseek", - "haiku35", + "haiku", "gpt-4o", "gpt-4.1-mini", "gemini2", @@ -401,7 +401,7 @@ async def tools_no_args(): "model_name", [ "deepseek", - "haiku35", + "haiku", # "gpt-4o", # "gpt-4.1", # "gpt-4.1-nano", @@ -439,7 +439,7 @@ async def test_tool_calls_no_args_typescript(fast_agent, model_name): "model_name", [ "deepseek", - "haiku35", + "haiku", "gpt-4.1", "google.gemini-2.0-flash", "gemini25", # Works -> DONE. diff --git a/tests/e2e/structured/fastagent.config.yaml b/tests/e2e/structured/fastagent.config.yaml index 5f54d5786..b1dcefa6d 100644 --- a/tests/e2e/structured/fastagent.config.yaml +++ b/tests/e2e/structured/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/e2e/workflow/fastagent.config.yaml b/tests/e2e/workflow/fastagent.config.yaml index bf9a8ea3b..5beb2ba23 100644 --- a/tests/e2e/workflow/fastagent.config.yaml +++ b/tests/e2e/workflow/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/acp/test_acp_auth_integration.py b/tests/integration/acp/test_acp_auth_integration.py index 30745ee12..9d8e84524 100644 --- a/tests/integration/acp/test_acp_auth_integration.py +++ b/tests/integration/acp/test_acp_auth_integration.py @@ -23,7 +23,7 @@ async def test_acp_initialize_survives_missing_provider_keys_and_prompts_fail_lazily( tmp_path: Path, ) -> None: - config_path = tmp_path / "fastagent.config.yaml" + config_path = tmp_path / "fast-agent.yaml" config_path.write_text( textwrap.dedent( """ @@ -97,7 +97,7 @@ async def test_acp_initialize_survives_missing_provider_keys_and_prompts_fail_la assert isinstance(data, dict) assert data["methodId"] == "fast-agent-ai-secrets" assert data["message"] == "OpenAI API key not configured" - assert data["configFile"] == "fastagent.secrets.yaml" + assert data["configFile"] == "fast-agent.secrets.yaml" assert data["docsUrl"] == "https://fast-agent.ai/ref/config_file/" assert data["envVars"] == ["OPENAI_API_KEY"] assert "fast-agent model setup" in data["recommendedCommands"] diff --git a/tests/integration/api/fastagent.config.markup.yaml b/tests/integration/api/fastagent.config.markup.yaml index 14ad717d7..172a1d5df 100644 --- a/tests/integration/api/fastagent.config.markup.yaml +++ b/tests/integration/api/fastagent.config.markup.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/api/fastagent.config.yaml b/tests/integration/api/fastagent.config.yaml index 275c92048..d3bb3c6e7 100644 --- a/tests/integration/api/fastagent.config.yaml +++ b/tests/integration/api/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/mcp_filtering/fastagent.config.yaml b/tests/integration/mcp_filtering/fastagent.config.yaml index a4419de58..cadfcd085 100644 --- a/tests/integration/mcp_filtering/fastagent.config.yaml +++ b/tests/integration/mcp_filtering/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/resources/fastagent.config.yaml b/tests/integration/resources/fastagent.config.yaml index a3891ccc0..a25b03ec0 100644 --- a/tests/integration/resources/fastagent.config.yaml +++ b/tests/integration/resources/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/tool_loop/fastagent.config.yaml b/tests/integration/tool_loop/fastagent.config.yaml index 1da0a6dfa..1937471f3 100644 --- a/tests/integration/tool_loop/fastagent.config.yaml +++ b/tests/integration/tool_loop/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/workflow/chain/fastagent.config.yaml b/tests/integration/workflow/chain/fastagent.config.yaml index f0414ae2e..cea0814ca 100644 --- a/tests/integration/workflow/chain/fastagent.config.yaml +++ b/tests/integration/workflow/chain/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/workflow/mixed/fastagent.config.yaml b/tests/integration/workflow/mixed/fastagent.config.yaml index b100b592a..a8da8b5a3 100644 --- a/tests/integration/workflow/mixed/fastagent.config.yaml +++ b/tests/integration/workflow/mixed/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/workflow/parallel/fastagent.config.yaml b/tests/integration/workflow/parallel/fastagent.config.yaml index b100b592a..a8da8b5a3 100644 --- a/tests/integration/workflow/parallel/fastagent.config.yaml +++ b/tests/integration/workflow/parallel/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/integration/workflow/router/fastagent.config.yaml b/tests/integration/workflow/router/fastagent.config.yaml index b100b592a..a8da8b5a3 100644 --- a/tests/integration/workflow/router/fastagent.config.yaml +++ b/tests/integration/workflow/router/fastagent.config.yaml @@ -4,7 +4,7 @@ # # Takes format: # .?reasoning= (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini?reasoning=low) -# Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3, opus46, opus47, opus46, opus47 +# Accepts aliases for Anthropic Models: haiku, haiku45, sonnet, sonnet4, sonnet46, opus, opus4, opus46, opus47 # and OpenAI Models: gpt-4o-mini, gpt-4o, o1, o1-mini, o3-mini # # If not specified, defaults to "haiku". diff --git a/tests/unit/acp/test_session_runtime_mcp_servers.py b/tests/unit/acp/test_session_runtime_mcp_servers.py index 57ac4c71e..d00885628 100644 --- a/tests/unit/acp/test_session_runtime_mcp_servers.py +++ b/tests/unit/acp/test_session_runtime_mcp_servers.py @@ -41,9 +41,16 @@ def visible_agent_names(self, *, force_include: str | None = None) -> list[str]: class _ShellRuntimeStub: - def __init__(self, *, timeout_seconds: int = 42, output_byte_limit: int = 1234) -> None: + def __init__( + self, + *, + timeout_seconds: int = 42, + output_byte_limit: int = 1234, + prefer_local_shell: bool = False, + ) -> None: self.timeout_seconds = timeout_seconds self.output_byte_limit = output_byte_limit + self.prefer_local_shell = prefer_local_shell class _ShellAgent(_Agent): @@ -849,3 +856,39 @@ async def fake_apply_session_mcp_overlay(*_args, **_kwargs) -> None: assert session_state.terminal_runtime is not None assert shell_agent.injected_runtime is session_state.terminal_runtime assert session_state.terminal_runtime.timeout_seconds == 42 + + +@pytest.mark.asyncio +async def test_initialize_session_state_skips_terminal_runtime_when_local_shell_preferred( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + shell_agent = _ShellAgent("main") + shell_agent._runtime = _ShellRuntimeStub(prefer_local_shell=True) + agent = cast("AgentProtocol", shell_agent) + instance = AgentInstance( + app=AgentApp({"main": agent}), + agents={"main": agent}, + registry_version=0, + ) + server = _build_session_server(instance, [instance]) + server._connection = cast("Any", object()) + server._client_supports_terminal = True + + async def fake_send_available_commands_update(_session_id: str) -> None: + return None + + async def fake_apply_session_mcp_overlay(*_args, **_kwargs) -> None: + return None + + monkeypatch.setattr(server, "_send_available_commands_update", fake_send_available_commands_update) + monkeypatch.setattr(server._session_runtime, "_apply_session_mcp_overlay", fake_apply_session_mcp_overlay) + + session_state, _ = await server._initialize_session_state( + "session-1", + cwd=str(tmp_path), + mcp_servers=[], + ) + + assert session_state.terminal_runtime is None + assert shell_agent.injected_runtime is None diff --git a/tests/unit/core/test_prompt_templates.py b/tests/unit/core/test_prompt_templates.py index d960c8337..07701568b 100644 --- a/tests/unit/core/test_prompt_templates.py +++ b/tests/unit/core/test_prompt_templates.py @@ -153,6 +153,7 @@ def test_enrich_with_environment_context_loads_skills(tmp_path): client_info = {"name": "test-client"} original_env_dir = os.environ.pop("ENVIRONMENT_DIR", None) + original_fast_agent_home = os.environ.pop("FAST_AGENT_HOME", None) import fast_agent.config as config_module original_settings = getattr(config_module, "_settings", None) config_module._settings = None @@ -162,6 +163,8 @@ def test_enrich_with_environment_context_loads_skills(tmp_path): config_module._settings = original_settings if original_env_dir is not None: os.environ["ENVIRONMENT_DIR"] = original_env_dir + if original_fast_agent_home is not None: + os.environ["FAST_AGENT_HOME"] = original_fast_agent_home # Verify skills were loaded assert "agentSkills" in context diff --git a/tests/unit/fast_agent/agents/test_mcp_agent_skills.py b/tests/unit/fast_agent/agents/test_mcp_agent_skills.py index 7b99afa56..d445aadcd 100644 --- a/tests/unit/fast_agent/agents/test_mcp_agent_skills.py +++ b/tests/unit/fast_agent/agents/test_mcp_agent_skills.py @@ -66,6 +66,50 @@ async def test_mcp_agent_exposes_skill_tools(tmp_path: Path) -> None: assert manifests[0].path.is_absolute() +@pytest.mark.asyncio +async def test_mcp_agent_skills_no_shell_uses_read_skill_fallback(tmp_path: Path) -> None: + skills_root = tmp_path / "skills" + create_skill(skills_root, "alpha", body="Alpha body") + + manifests = SkillRegistry.load_directory(skills_root) + context = Context() + context.no_shell = True + + config = AgentConfig(name="test", instruction="Instruction", servers=[], skills=skills_root) + config.skill_manifests = manifests + + agent = McpAgent(config=config, context=context) + + assert agent.shell_runtime_enabled is False + tools_result = await agent.list_tools() + tool_names = {tool.name for tool in tools_result.tools} + assert "execute" not in tool_names + assert "read_text_file" not in tool_names + assert "read_skill" in tool_names + + +@pytest.mark.asyncio +async def test_mcp_agent_no_shell_overrides_agent_shell_config(tmp_path: Path) -> None: + context = Context() + context.no_shell = True + + config = AgentConfig( + name="test", + instruction="Instruction", + servers=[], + shell=True, + cwd=tmp_path / "missing", + ) + + agent = McpAgent(config=config, context=context) + + assert agent.shell_runtime_enabled is False + tools_result = await agent.list_tools() + tool_names = {tool.name for tool in tools_result.tools} + assert "execute" not in tool_names + assert "read_text_file" not in tool_names + + @pytest.mark.asyncio async def test_mcp_agent_skills_default_uses_context_registry(tmp_path: Path) -> None: skills_root = tmp_path / "skills" diff --git a/tests/unit/fast_agent/agents/test_tool_agent_structured_schema.py b/tests/unit/fast_agent/agents/test_tool_agent_structured_schema.py new file mode 100644 index 000000000..1d25a7920 --- /dev/null +++ b/tests/unit/fast_agent/agents/test_tool_agent_structured_schema.py @@ -0,0 +1,167 @@ +import pytest +from mcp import CallToolRequest +from mcp.types import CallToolRequestParams, Tool + +from fast_agent.agents.agent_types import AgentConfig +from fast_agent.agents.tool_agent import ToolAgent +from fast_agent.core.prompt import Prompt +from fast_agent.llm.internal.passthrough import PassthroughLLM +from fast_agent.llm.request_params import RequestParams +from fast_agent.mcp.helpers.content_helpers import text_content +from fast_agent.mcp.prompt_message_extended import PromptMessageExtended +from fast_agent.types.llm_stop_reason import LlmStopReason + + +class ToolThenStructuredLlm(PassthroughLLM): + def __init__(self) -> None: + super().__init__() + self.call_count = 0 + self.tool_counts: list[int] = [] + self.structured_schemas: list[dict | None] = [] + + async def _apply_prompt_provider_specific( + self, + multipart_messages: list[PromptMessageExtended], + request_params: RequestParams | None = None, + tools: list[Tool] | None = None, + is_template: bool = False, + ) -> PromptMessageExtended: + del multipart_messages, is_template + self.call_count += 1 + self.tool_counts.append(len(tools or [])) + self.structured_schemas.append( + request_params.structured_schema if request_params is not None else None + ) + + if self.call_count == 1: + return PromptMessageExtended( + role="assistant", + content=[text_content("use tool")], + stop_reason=LlmStopReason.TOOL_USE, + tool_calls={ + "call_1": CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name="get_value", arguments={}), + ) + }, + ) + + return Prompt.assistant('{"value":"from-tool"}', stop_reason=LlmStopReason.END_TURN) + + +class NoToolThenStructuredLlm(PassthroughLLM): + def __init__(self) -> None: + super().__init__() + self.call_count = 0 + self.tool_counts: list[int] = [] + self.messages: list[list[str]] = [] + + async def _apply_prompt_provider_specific( + self, + multipart_messages: list[PromptMessageExtended], + request_params: RequestParams | None = None, + tools: list[Tool] | None = None, + is_template: bool = False, + ) -> PromptMessageExtended: + del request_params, is_template + self.call_count += 1 + self.tool_counts.append(len(tools or [])) + self.messages.append([message.all_text() for message in multipart_messages]) + + if self.call_count == 1: + return Prompt.assistant("no tool needed", stop_reason=LlmStopReason.END_TURN) + + return Prompt.assistant('{"value":"final"}', stop_reason=LlmStopReason.END_TURN) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_tool_agent_structured_schema_uses_tool_runner_generate_path() -> None: + tool_call_count = 0 + + def get_value() -> str: + nonlocal tool_call_count + tool_call_count += 1 + return "from-tool" + + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": False, + } + llm = ToolThenStructuredLlm() + agent = ToolAgent(AgentConfig("structured"), [get_value]) + agent._llm = llm + + parsed, response = await agent.structured_schema( + "call the tool, then return JSON", + schema, + RequestParams(use_history=False, max_iterations=3), + ) + + assert parsed == {"value": "from-tool"} + assert response.last_text() == '{"value":"from-tool"}' + assert tool_call_count == 1 + assert llm.call_count == 2 + assert llm.tool_counts == [1, 1] + assert llm.structured_schemas == [schema, schema] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_deferred_structured_schema_finalizes_when_no_tool_is_called() -> None: + def get_value() -> str: + return "unused" + + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": False, + } + llm = NoToolThenStructuredLlm() + agent = ToolAgent(AgentConfig("structured"), [get_value]) + agent._llm = llm + + parsed, response = await agent.structured_schema( + "return JSON, using a tool only if needed", + schema, + RequestParams( + use_history=False, + max_iterations=3, + structured_tool_policy="defer", + ), + ) + + assert parsed == {"value": "final"} + assert response.last_text() == '{"value":"final"}' + assert llm.call_count == 2 + assert llm.tool_counts == [1, 0] + assert any("no tool needed" in text for text in llm.messages[1]) + assert any("Now produce the final answer as structured JSON" in text for text in llm.messages[1]) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_deferred_structured_schema_without_tools_uses_single_call() -> None: + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": False, + } + llm = NoToolThenStructuredLlm() + agent = ToolAgent(AgentConfig("structured"), []) + agent._llm = llm + + parsed, response = await agent.structured_schema( + "return JSON", + schema, + RequestParams(use_history=False, max_iterations=3, structured_tool_policy="defer"), + ) + + assert parsed is None + assert response.last_text() == "no tool needed" + assert llm.call_count == 1 + assert llm.tool_counts == [0] diff --git a/tests/unit/fast_agent/batch/test_input.py b/tests/unit/fast_agent/batch/test_input.py new file mode 100644 index 000000000..90a9be16a --- /dev/null +++ b/tests/unit/fast_agent/batch/test_input.py @@ -0,0 +1,54 @@ +import json + +from fast_agent.batch.input import iter_csv_rows, iter_jsonl_rows, select_rows + + +def test_jsonl_rows_are_dicts(tmp_path): + path = tmp_path / "rows.jsonl" + path.write_text('{"id": "1", "message": "hello"}\n\n{"id": "2"}\n', encoding="utf-8") + + rows = list(iter_jsonl_rows(path)) + + assert [row.row_number for row in rows] == [1, 3] + assert rows[0].row == {"id": "1", "message": "hello"} + assert rows[1].row == {"id": "2"} + + +def test_invalid_jsonl_lines_become_row_errors(tmp_path): + path = tmp_path / "rows.jsonl" + path.write_text('{"ok": true}\nnot-json\n[]\n', encoding="utf-8") + + rows = list(iter_jsonl_rows(path)) + + assert rows[1].error is not None + assert rows[1].error.type == "InvalidJSON" + assert rows[2].error is not None + assert rows[2].error.type == "InvalidRow" + + +def test_csv_rows_are_dicts(tmp_path): + path = tmp_path / "rows.csv" + path.write_text("id,message\n1,hello\n2,world\n", encoding="utf-8") + + rows = list(iter_csv_rows(path)) + + assert [row.row for row in rows] == [ + {"id": "1", "message": "hello"}, + {"id": "2", "message": "world"}, + ] + + +def test_selection_order_is_offset_sample_restore_order_then_limit(tmp_path): + path = tmp_path / "rows.jsonl" + path.write_text( + "\n".join(json.dumps({"id": index}) for index in range(10)) + "\n", + encoding="utf-8", + ) + rows = list(iter_jsonl_rows(path)) + + selected = select_rows(rows, offset=2, sample=5, seed=7, limit=2) + + full_sample = select_rows(rows, offset=2, sample=5, seed=7) + assert selected == full_sample[:2] + assert [row.row_number for row in selected] == sorted(row.row_number for row in selected) + diff --git a/tests/unit/fast_agent/batch/test_resume_output.py b/tests/unit/fast_agent/batch/test_resume_output.py new file mode 100644 index 000000000..dfc605e3e --- /dev/null +++ b/tests/unit/fast_agent/batch/test_resume_output.py @@ -0,0 +1,61 @@ +import json + +import pytest + +from fast_agent.batch.input import RowError +from fast_agent.batch.output import error_envelope, success_envelope +from fast_agent.batch.resume import load_completed_ids + + +def test_output_envelopes_are_stable(): + success = success_envelope( + identity="001", + row_number=1, + result={"category": "billing"}, + row={"id": "001"}, + include_input=True, + ) + failure = error_envelope( + identity="002", + row_number=2, + error=RowError("Oops", "bad row"), + row={"id": "002"}, + include_input=False, + ) + + assert success == { + "id": "001", + "row_number": 1, + "ok": True, + "result": {"category": "billing"}, + "error": None, + "input": {"id": "001"}, + } + assert failure == { + "id": "002", + "row_number": 2, + "ok": False, + "result": None, + "error": {"type": "Oops", "message": "bad row"}, + } + + +def test_resume_loads_only_successful_ids_and_normalizes_to_string(tmp_path): + path = tmp_path / "out.jsonl" + records = [ + {"id": 123, "ok": True}, + {"id": "456", "ok": False}, + {"id": "789", "ok": True}, + ] + path.write_text("\n".join(json.dumps(record) for record in records) + "\n", encoding="utf-8") + + assert load_completed_ids(path) == {"123", "789"} + + +def test_resume_fails_on_malformed_existing_output(tmp_path): + path = tmp_path / "out.jsonl" + path.write_text('{"id": "1", "ok": true}\nnot-json\n', encoding="utf-8") + + with pytest.raises(ValueError, match="Invalid JSONL"): + load_completed_ids(path) + diff --git a/tests/unit/fast_agent/batch/test_structured_preflight.py b/tests/unit/fast_agent/batch/test_structured_preflight.py new file mode 100644 index 000000000..a0c5af55f --- /dev/null +++ b/tests/unit/fast_agent/batch/test_structured_preflight.py @@ -0,0 +1,121 @@ +import pytest +from pydantic import BaseModel + +from fast_agent.batch.structured import ( + StructuredBatchOptions, + load_json_schema, + load_pydantic_model, + load_schema_source, + run_structured_batch, +) + + +class ImportedResult(BaseModel): + value: str + + +def test_schema_load_failure_is_preflight_error(tmp_path): + schema = tmp_path / "schema.json" + schema.write_text("[]", encoding="utf-8") + + with pytest.raises(ValueError, match="must contain a JSON object"): + load_json_schema(schema) + + +@pytest.mark.asyncio +async def test_resume_and_overwrite_are_mutually_exclusive(tmp_path): + input_path = tmp_path / "rows.jsonl" + input_path.write_text('{"id":"1"}\n', encoding="utf-8") + schema = tmp_path / "schema.json" + schema.write_text('{"type":"object"}', encoding="utf-8") + + options = StructuredBatchOptions( + input_path=input_path, + output_path=tmp_path / "out.jsonl", + schema_path=schema, + resume=True, + overwrite=True, + ) + + with pytest.raises(ValueError, match="cannot be used together"): + await run_structured_batch(options) + + +def test_schema_file_and_schema_model_are_mutually_exclusive(tmp_path): + schema = tmp_path / "schema.json" + schema.write_text('{"type":"object"}', encoding="utf-8") + options = StructuredBatchOptions( + input_path=tmp_path / "rows.jsonl", + output_path=tmp_path / "out.jsonl", + schema_path=schema, + schema_model="example:Result", + ) + + with pytest.raises(ValueError, match="cannot be used together"): + load_schema_source(options) + + +def test_one_schema_source_is_required(tmp_path): + options = StructuredBatchOptions( + input_path=tmp_path / "rows.jsonl", + output_path=tmp_path / "out.jsonl", + ) + + with pytest.raises(ValueError, match="One of --schema or --schema-model is required"): + load_schema_source(options) + + +def test_load_pydantic_model_from_import_path(): + loaded = load_pydantic_model(f"{__name__}:ImportedResult") + + assert loaded is ImportedResult + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("duplicate_field", "expected_flag"), + [ + ("error_output_path", "--error-output"), + ("telemetry_output_path", "--telemetry-output"), + ("summary_output_path", "--summary-output"), + ], +) +async def test_optional_output_paths_cannot_match_primary_output( + tmp_path, + duplicate_field, + expected_flag, +): + input_path = tmp_path / "rows.jsonl" + schema = tmp_path / "schema.json" + output_path = tmp_path / "out.jsonl" + + options = StructuredBatchOptions( + input_path=input_path, + output_path=output_path, + schema_path=schema, + **{duplicate_field: output_path}, + ) + + with pytest.raises(ValueError, match=rf"{expected_flag}.*--output"): + await run_structured_batch(options) + + +@pytest.mark.asyncio +async def test_optional_output_paths_cannot_match_each_other_after_resolution(tmp_path): + input_path = tmp_path / "rows.jsonl" + schema = tmp_path / "schema.json" + error_output = tmp_path / "errors.jsonl" + telemetry_link = tmp_path / "telemetry.jsonl" + error_output.touch() + telemetry_link.symlink_to(error_output) + + options = StructuredBatchOptions( + input_path=input_path, + output_path=tmp_path / "out.jsonl", + schema_path=schema, + error_output_path=error_output, + telemetry_output_path=telemetry_link, + ) + + with pytest.raises(ValueError, match=r"--telemetry-output.*--error-output"): + await run_structured_batch(options) diff --git a/tests/unit/fast_agent/batch/test_template.py b/tests/unit/fast_agent/batch/test_template.py new file mode 100644 index 000000000..599e7a24c --- /dev/null +++ b/tests/unit/fast_agent/batch/test_template.py @@ -0,0 +1,33 @@ +import json + +from fast_agent.batch.template import DEFAULT_ROW_TEMPLATE, render_row_template + + +def test_default_template_dumps_pretty_row_json(): + rendered, error = render_row_template(DEFAULT_ROW_TEMPLATE, {"id": "1", "count": 2}) + + assert error is None + assert rendered is not None + assert "Input record:" in rendered + assert json.dumps({"id": "1", "count": 2}, indent=2) in rendered + + +def test_template_renders_field_placeholders_and_row_json(): + rendered, error = render_row_template( + "Message: {{message}}\nPayload:\n{{row_json}}", + {"message": "hello", "tags": ["a"]}, + ) + + assert error is None + assert rendered is not None + assert "Message: hello" in rendered + assert '"tags": [\n "a"\n ]' in rendered + + +def test_missing_template_field_returns_row_error(): + rendered, error = render_row_template("{{missing}}", {"message": "hello"}) + + assert rendered is None + assert error is not None + assert error.type == "MissingTemplateField" + diff --git a/tests/unit/fast_agent/cli/commands/test_model_setup_command.py b/tests/unit/fast_agent/cli/commands/test_model_setup_command.py index e803f1f5b..80b5d7f96 100644 --- a/tests/unit/fast_agent/cli/commands/test_model_setup_command.py +++ b/tests/unit/fast_agent/cli/commands/test_model_setup_command.py @@ -125,7 +125,7 @@ async def test_run_model_setup_creates_alias_in_env_config(tmp_path: Path) -> No finally: os.chdir(previous_cwd) - saved = _read_yaml(env_dir / "fastagent.config.yaml") + saved = _read_yaml(env_dir / "fast-agent.yaml") assert saved["model_references"]["system"]["fast"] == "claude-haiku-4-5" assert outcome.messages assert "model references set" in str(outcome.messages[0].text) @@ -152,7 +152,7 @@ async def test_run_model_setup_prefills_system_default_alias_when_no_aliases_exi finally: os.chdir(previous_cwd) - saved = _read_yaml(env_dir / "fastagent.config.yaml") + saved = _read_yaml(env_dir / "fast-agent.yaml") assert saved["model_references"]["system"]["default"] == "claude-haiku-4-5" assert io.prompt_text_calls == [ ("Reference token ($namespace.key):", "$system.default", False) @@ -168,7 +168,7 @@ async def test_run_model_setup_repairs_invalid_default_alias_from_diagnostics( workspace = tmp_path / "workspace" env_dir = workspace / ".model-env" workspace.mkdir(parents=True) - (workspace / "fastagent.config.yaml").write_text( + (workspace / "fast-agent.yaml").write_text( 'default_model: "$system.default"\n' "model_references:\n" " system:\n" @@ -189,7 +189,7 @@ async def test_run_model_setup_repairs_invalid_default_alias_from_diagnostics( finally: os.chdir(previous_cwd) - saved = _read_yaml(env_dir / "fastagent.config.yaml") + saved = _read_yaml(env_dir / "fast-agent.yaml") assert saved["model_references"]["system"]["default"] == "claude-haiku-4-5" assert io.prompt_text_calls == [] assert outcome.messages @@ -201,7 +201,7 @@ async def test_run_model_setup_updates_named_alias_via_model_selector(tmp_path: workspace = tmp_path / "workspace" env_dir = workspace / ".model-env" workspace.mkdir(parents=True) - config_path = env_dir / "fastagent.config.yaml" + config_path = env_dir / "fast-agent.yaml" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text( "model_references:\n system:\n fast: claude-sonnet-4-5\n", @@ -234,7 +234,7 @@ async def test_run_model_doctor_reports_unresolved_default_alias(tmp_path: Path) workspace = tmp_path / "workspace" env_dir = workspace / ".model-env" workspace.mkdir(parents=True) - (workspace / "fastagent.config.yaml").write_text( + (workspace / "fast-agent.yaml").write_text( 'default_model: "$system.default"\n', encoding="utf-8", ) @@ -264,7 +264,7 @@ async def test_run_model_doctor_uses_environment_dir_parent_when_cwd_differs(tmp env_dir = workspace / ".model-env" workspace.mkdir(parents=True) elsewhere.mkdir(parents=True) - (workspace / "fastagent.config.yaml").write_text( + (workspace / "fast-agent.yaml").write_text( 'default_model: "$system.default"\n', encoding="utf-8", ) diff --git a/tests/unit/fast_agent/command_actions/test_loader.py b/tests/unit/fast_agent/command_actions/test_loader.py new file mode 100644 index 000000000..e2eeac664 --- /dev/null +++ b/tests/unit/fast_agent/command_actions/test_loader.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from fast_agent.command_actions import ( + PluginCommandActionContext, + PluginCommandActionRegistry, + PluginCommandActionResult, + normalize_plugin_command_action_result, +) +from fast_agent.command_actions.loader import load_plugin_command_action_function +from fast_agent.command_actions.models import PluginCommandActionSpec +from fast_agent.core.exceptions import AgentConfigError + +if TYPE_CHECKING: + from pathlib import Path + + +def test_load_plugin_command_action_function_accepts_async_handler(tmp_path: Path) -> None: + module_path = tmp_path / "commands.py" + module_path.write_text( + "async def run(ctx):\n" + " return 'ok'\n", + encoding="utf-8", + ) + + handler = load_plugin_command_action_function("commands.py:run", base_path=tmp_path) + + assert handler.__name__ == "run" + + +def test_load_plugin_command_action_function_rejects_sync_handler(tmp_path: Path) -> None: + module_path = tmp_path / "commands.py" + module_path.write_text( + "def run(ctx):\n" + " return 'ok'\n", + encoding="utf-8", + ) + + with pytest.raises(AgentConfigError, match="must be async"): + load_plugin_command_action_function("commands.py:run", base_path=tmp_path) + + +@pytest.mark.asyncio +async def test_registry_executes_and_normalizes_string_result(tmp_path: Path) -> None: + module_path = tmp_path / "commands.py" + module_path.write_text( + "async def greet(ctx):\n" + " return f'hello {ctx.arguments}'\n", + encoding="utf-8", + ) + registry = PluginCommandActionRegistry.from_specs( + { + "greet": PluginCommandActionSpec( + name="greet", + description="Greet", + handler="commands.py:greet", + ) + }, + base_path=tmp_path, + ) + + result = await registry.execute( + "greet", + PluginCommandActionContext( + command_name="greet", + arguments="world", + agent=_CommandAgent(), + ), + ) + + assert result == PluginCommandActionResult(message="hello world") + + +def test_normalize_plugin_command_action_result_handles_none_and_strings() -> None: + assert normalize_plugin_command_action_result(None) == PluginCommandActionResult() + assert normalize_plugin_command_action_result("ok") == PluginCommandActionResult(message="ok") + + +class _CommandAgent: + name = "agent" + context = None + config = None + agent_registry = None + message_history = [] + + def load_message_history(self, messages): + self.message_history = messages or [] + + def get_agent(self, name: str): + return None + + async def send(self, message: str) -> str: + return message diff --git a/tests/unit/fast_agent/commands/test_acp_command.py b/tests/unit/fast_agent/commands/test_acp_command.py index 318fed50e..0af158b9b 100644 --- a/tests/unit/fast_agent/commands/test_acp_command.py +++ b/tests/unit/fast_agent/commands/test_acp_command.py @@ -114,3 +114,39 @@ def test_acp_command_builds_request_with_missing_shell_cwd_override() -> None: ) assert request.missing_shell_cwd_policy == "create" + + +def test_acp_command_builds_request_with_prefer_local_shell() -> None: + ctx = typer.Context(click.Command("acp")) + request = acp_command._build_run_request( + ctx=ctx, + name="fast-agent-acp", + instruction=None, + config_path=None, + servers=None, + agent_cards=None, + card_tools=None, + urls=None, + auth=None, + client_metadata_url=None, + model=None, + env_dir=None, + noenv=False, + force_smart=False, + skills_dir=None, + npx=None, + uvx=None, + stdio=None, + description=None, + host="127.0.0.1", + port=8010, + shell=True, + prefer_local_shell=True, + no_permissions=False, + resume=None, + reload=False, + watch=False, + ) + + assert request.shell_runtime is True + assert request.prefer_local_shell is True diff --git a/tests/unit/fast_agent/commands/test_batch_command.py b/tests/unit/fast_agent/commands/test_batch_command.py new file mode 100644 index 000000000..155ec40de --- /dev/null +++ b/tests/unit/fast_agent/commands/test_batch_command.py @@ -0,0 +1,151 @@ +import json +import sys + +from typer.testing import CliRunner + +from fast_agent.cli.main import app + + +def test_batch_structured_direct_mode_with_passthrough(tmp_path): + env_dir = tmp_path / "env" + env_dir.mkdir() + input_path = tmp_path / "rows.jsonl" + output_path = tmp_path / "out.jsonl" + schema_path = tmp_path / "schema.json" + template_path = tmp_path / "row.md" + + input_path.write_text('{"id":"1","x":2}\n', encoding="utf-8") + schema_path.write_text('{"type":"object"}', encoding="utf-8") + template_path.write_text("{{row_json}}", encoding="utf-8") + + result = CliRunner().invoke( + app, + [ + "--no-update-check", + "--env", + str(env_dir), + "batch", + "structured", + "--input", + str(input_path), + "--output", + str(output_path), + "--schema", + str(schema_path), + "--template", + str(template_path), + "--model", + "passthrough", + "--id-field", + "id", + "--include-input", + "--no-final-summary", + ], + ) + + assert result.exit_code == 0, result.output + record = json.loads(output_path.read_text(encoding="utf-8")) + assert record == { + "id": "1", + "row_number": 1, + "ok": True, + "result": {"id": "1", "x": 2}, + "error": None, + "input": {"id": "1", "x": 2}, + } + + +def test_batch_structured_accepts_pydantic_schema_model(tmp_path): + env_dir = tmp_path / "env" + env_dir.mkdir() + input_path = tmp_path / "rows.jsonl" + output_path = tmp_path / "out.jsonl" + template_path = tmp_path / "row.md" + schema_module = tmp_path / "batch_schemas.py" + + input_path.write_text('{"id":"1","x":2}\n', encoding="utf-8") + template_path.write_text("{{row_json}}", encoding="utf-8") + schema_module.write_text( + "from pydantic import BaseModel\n\n" + "class RowResult(BaseModel):\n" + " id: str\n" + " x: int\n", + encoding="utf-8", + ) + + sys.path.insert(0, str(tmp_path)) + try: + result = CliRunner().invoke( + app, + [ + "--no-update-check", + "--env", + str(env_dir), + "batch", + "structured", + "--input", + str(input_path), + "--output", + str(output_path), + "--schema-model", + "batch_schemas:RowResult", + "--template", + str(template_path), + "--model", + "passthrough", + "--id-field", + "id", + "--no-final-summary", + ], + ) + finally: + sys.path.remove(str(tmp_path)) + sys.modules.pop("batch_schemas", None) + + assert result.exit_code == 0, result.output + record = json.loads(output_path.read_text(encoding="utf-8")) + assert record["ok"] is True + assert record["result"] == {"id": "1", "x": 2} + + +def test_batch_structured_accepts_shell_runtime_flag(tmp_path): + env_dir = tmp_path / "env" + env_dir.mkdir() + input_path = tmp_path / "rows.jsonl" + output_path = tmp_path / "out.jsonl" + schema_path = tmp_path / "schema.json" + summary_path = tmp_path / "summary.json" + template_path = tmp_path / "row.md" + + input_path.write_text('{"id":"1","x":2}\n', encoding="utf-8") + schema_path.write_text('{"type":"object"}', encoding="utf-8") + template_path.write_text("{{row_json}}", encoding="utf-8") + + result = CliRunner().invoke( + app, + [ + "--no-update-check", + "--env", + str(env_dir), + "batch", + "structured", + "-x", + "--input", + str(input_path), + "--output", + str(output_path), + "--schema", + str(schema_path), + "--template", + str(template_path), + "--model", + "passthrough", + "--summary-output", + str(summary_path), + "--no-final-summary", + ], + ) + + assert result.exit_code == 0, result.output + summary = json.loads(summary_path.read_text(encoding="utf-8")) + assert summary["shell_runtime"] is True diff --git a/tests/unit/fast_agent/commands/test_cli_constants.py b/tests/unit/fast_agent/commands/test_cli_constants.py index 4b5a3024c..bb8279ae9 100644 --- a/tests/unit/fast_agent/commands/test_cli_constants.py +++ b/tests/unit/fast_agent/commands/test_cli_constants.py @@ -17,6 +17,7 @@ def test_go_specific_options_include_agent_and_noenv() -> None: assert "--agent" in GO_SPECIFIC_OPTIONS assert "--noenv" in GO_SPECIFIC_OPTIONS assert "--no-env" in GO_SPECIFIC_OPTIONS + assert "--no-shell" in GO_SPECIFIC_OPTIONS def test_go_specific_options_include_smart() -> None: diff --git a/tests/unit/fast_agent/commands/test_cli_main_routing.py b/tests/unit/fast_agent/commands/test_cli_main_routing.py index 8298faeb1..e2f5bdb73 100644 --- a/tests/unit/fast_agent/commands/test_cli_main_routing.py +++ b/tests/unit/fast_agent/commands/test_cli_main_routing.py @@ -81,6 +81,15 @@ def test_auto_routes_to_go_when_pack_flag_used_at_root() -> None: assert "--pack-registry" in output +def test_auto_routes_to_go_when_no_shell_used_at_root() -> None: + result = _run_fast_agent_cli("--no-shell", "--help") + output = strip_ansi(result.stdout) + + assert result.returncode == 0, result.stderr + assert "go [OPTIONS] COMMAND" in output + assert "--no-shell" in output + + def test_demo_subcommand_still_detected_after_env_option_value() -> None: result = _run_fast_agent_cli("--env", "demo", "demo", "--help") output = strip_ansi(result.stdout) diff --git a/tests/unit/fast_agent/commands/test_config_command.py b/tests/unit/fast_agent/commands/test_config_command.py index 6314a9b19..81caadd6f 100644 --- a/tests/unit/fast_agent/commands/test_config_command.py +++ b/tests/unit/fast_agent/commands/test_config_command.py @@ -118,7 +118,7 @@ def test_load_config_defaults_to_environment_config_path(tmp_path: Path, monkeyp monkeypatch.chdir(workspace) monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) - expected = workspace / ".fast-agent" / "fastagent.config.yaml" + expected = workspace / ".fast-agent" / "fast-agent.yaml" config_data, config_path = config_command._load_config() @@ -152,7 +152,7 @@ def test_load_config_prefers_cwd_config_before_legacy( assert config_data == {"logger": {"show_tools": True}} -def test_load_config_falls_back_to_legacy_parent_config( +def test_load_config_ignores_parent_config( tmp_path: Path, monkeypatch ) -> None: workspace = tmp_path / "workspace" @@ -169,11 +169,11 @@ def test_load_config_falls_back_to_legacy_parent_config( config_data, config_path = config_command._load_config() - assert config_path == workspace / "fastagent.config.yaml" - assert config_data == {"logger": {"show_tools": False}} + assert config_path == nested / ".fast-agent" / "fast-agent.yaml" + assert config_data == {} -def test_config_display_updates_legacy_parent_config_when_run_from_nested_dir( +def test_config_display_writes_selected_home_config_when_parent_config_exists( tmp_path: Path, monkeypatch ) -> None: workspace = tmp_path / "workspace" @@ -213,7 +213,7 @@ def _fake_form_sync(*args, **kwargs): # noqa: ARG001 assert result.exit_code == 0, result.output config_data, config_path = config_command._load_config() - assert config_path == workspace / "fastagent.config.yaml" + assert config_path == nested / ".fast-agent" / "fast-agent.yaml" logger = config_data["logger"] assert logger["show_tools"] is False assert logger["show_chat"] is False diff --git a/tests/unit/fast_agent/commands/test_env_helpers.py b/tests/unit/fast_agent/commands/test_env_helpers.py index eef299c24..afeee5f7c 100644 --- a/tests/unit/fast_agent/commands/test_env_helpers.py +++ b/tests/unit/fast_agent/commands/test_env_helpers.py @@ -4,10 +4,12 @@ from pathlib import Path from fast_agent.cli.env_helpers import resolve_environment_dir_option +from fast_agent.constants import FAST_AGENT_RUNTIME_ENVIRONMENT def test_resolve_environment_dir_option_returns_absolute_path(tmp_path: Path) -> None: original_env = os.environ.get("ENVIRONMENT_DIR") + original_runtime_env = os.environ.get(FAST_AGENT_RUNTIME_ENVIRONMENT) original_cwd = Path.cwd() workspace = tmp_path / "workspace" workspace.mkdir() @@ -18,21 +20,30 @@ def test_resolve_environment_dir_option_returns_absolute_path(tmp_path: Path) -> resolved = resolve_environment_dir_option(None, Path(".dev")) assert resolved == (workspace / ".dev").resolve() assert os.environ.get("ENVIRONMENT_DIR") == str((workspace / ".dev").resolve()) + assert os.environ.get(FAST_AGENT_RUNTIME_ENVIRONMENT) == str( + (workspace / ".dev").resolve() + ) finally: os.chdir(original_cwd) if original_env is None: os.environ.pop("ENVIRONMENT_DIR", None) else: os.environ["ENVIRONMENT_DIR"] = original_env + if original_runtime_env is None: + os.environ.pop(FAST_AGENT_RUNTIME_ENVIRONMENT, None) + else: + os.environ[FAST_AGENT_RUNTIME_ENVIRONMENT] = original_runtime_env def test_resolve_environment_dir_option_can_skip_environment_mutation(tmp_path: Path) -> None: original_env = os.environ.get("ENVIRONMENT_DIR") + original_runtime_env = os.environ.get(FAST_AGENT_RUNTIME_ENVIRONMENT) original_cwd = Path.cwd() workspace = tmp_path / "workspace" workspace.mkdir() os.environ["ENVIRONMENT_DIR"] = "do-not-change" + os.environ[FAST_AGENT_RUNTIME_ENVIRONMENT] = "do-not-change" try: os.chdir(workspace) resolved = resolve_environment_dir_option( @@ -42,9 +53,14 @@ def test_resolve_environment_dir_option_can_skip_environment_mutation(tmp_path: ) assert resolved == (workspace / ".dev").resolve() assert os.environ.get("ENVIRONMENT_DIR") == "do-not-change" + assert os.environ.get(FAST_AGENT_RUNTIME_ENVIRONMENT) == "do-not-change" finally: os.chdir(original_cwd) if original_env is None: os.environ.pop("ENVIRONMENT_DIR", None) else: os.environ["ENVIRONMENT_DIR"] = original_env + if original_runtime_env is None: + os.environ.pop(FAST_AGENT_RUNTIME_ENVIRONMENT, None) + else: + os.environ[FAST_AGENT_RUNTIME_ENVIRONMENT] = original_runtime_env diff --git a/tests/unit/fast_agent/commands/test_models_manager_handler.py b/tests/unit/fast_agent/commands/test_models_manager_handler.py index e69ee6d03..b61f4fe92 100644 --- a/tests/unit/fast_agent/commands/test_models_manager_handler.py +++ b/tests/unit/fast_agent/commands/test_models_manager_handler.py @@ -192,13 +192,13 @@ def _context_with_io( @pytest.mark.asyncio -async def test_models_aliases_lists_layered_alias_values(tmp_path: Path) -> None: +async def test_models_aliases_lists_selected_home_alias_values(tmp_path: Path) -> None: workspace = tmp_path / "workspace" env_dir = workspace / ".fast-agent" workspace.mkdir(parents=True) _write_yaml( - workspace / "fastagent.config.yaml", + workspace / "fast-agent.yaml", { "model_references": { "system": { @@ -209,7 +209,7 @@ async def test_models_aliases_lists_layered_alias_values(tmp_path: Path) -> None }, ) _write_yaml( - env_dir / "fastagent.config.yaml", + env_dir / "fast-agent.yaml", { "model_references": { "system": { @@ -236,7 +236,7 @@ async def test_models_aliases_lists_layered_alias_values(tmp_path: Path) -> None assert "▎ model references" in rendered assert "▎•" not in rendered assert "$system.fast = env-fast" in rendered - assert "$system.code = project-code" in rendered + assert "$system.code = project-code" not in rendered @pytest.mark.asyncio @@ -279,7 +279,7 @@ async def test_models_doctor_lists_all_agents_including_tool_only(tmp_path: Path workspace.mkdir(parents=True) _write_yaml( - env_dir / "fastagent.config.yaml", + env_dir / "fast-agent.yaml", { "model_references": { "system": { @@ -393,7 +393,7 @@ async def test_models_doctor_dedupes_repeated_alias_missing_note(tmp_path: Path) assert outcome.messages rendered = str(outcome.messages[0].text) - expected_note = "No model_references are configured. Add a model_references section in fastagent.config.yaml." + expected_note = "No model_references are configured. Add a model_references section in fast-agent.yaml." assert rendered.count(expected_note) == 1 @@ -499,7 +499,7 @@ async def test_models_aliases_set_writes_env_target(tmp_path: Path) -> None: finally: os.chdir(previous_cwd) - config_path = env_dir / "fastagent.config.yaml" + config_path = env_dir / "fast-agent.yaml" assert config_path.exists() saved = _read_yaml(config_path) assert saved["model_references"]["system"]["fast"] == "claude-haiku-4-5" @@ -519,7 +519,7 @@ async def test_models_aliases_set_uses_model_selector_for_existing_alias(tmp_pat env_dir = workspace / ".fast-agent" workspace.mkdir(parents=True) _write_yaml( - env_dir / "fastagent.config.yaml", + env_dir / "fast-agent.yaml", { "model_references": { "system": { @@ -543,7 +543,7 @@ async def test_models_aliases_set_uses_model_selector_for_existing_alias(tmp_pat finally: os.chdir(previous_cwd) - saved = _read_yaml(env_dir / "fastagent.config.yaml") + saved = _read_yaml(env_dir / "fast-agent.yaml") assert saved["model_references"]["system"]["fast"] == "claude-haiku-4-5" rendered = str(outcome.messages[0].text) @@ -559,7 +559,7 @@ async def test_models_aliases_set_reopens_vertex_selection_for_vertex_model(tmp_ env_dir = workspace / ".fast-agent" workspace.mkdir(parents=True) _write_yaml( - env_dir / "fastagent.config.yaml", + env_dir / "fast-agent.yaml", { "model_references": { "system": { @@ -611,7 +611,7 @@ async def test_models_aliases_set_can_create_new_alias_interactively(tmp_path: P finally: os.chdir(previous_cwd) - saved = _read_yaml(env_dir / "fastagent.config.yaml") + saved = _read_yaml(env_dir / "fast-agent.yaml") assert saved["model_references"]["custom"]["review"] == "gpt-4.1-mini" rendered = str(outcome.messages[0].text) @@ -627,7 +627,7 @@ async def test_models_aliases_set_can_choose_existing_alias_by_number(tmp_path: env_dir = workspace / ".fast-agent" workspace.mkdir(parents=True) _write_yaml( - env_dir / "fastagent.config.yaml", + env_dir / "fast-agent.yaml", { "model_references": { "system": { @@ -654,11 +654,11 @@ async def test_models_aliases_set_can_choose_existing_alias_by_number(tmp_path: finally: os.chdir(previous_cwd) - saved = _read_yaml(env_dir / "fastagent.config.yaml") + saved = _read_yaml(env_dir / "fast-agent.yaml") assert saved["model_references"]["system"]["fast"] == "gpt-4.1-mini" assert io.emitted_messages assert _message_text(io.emitted_messages[0]).find( - str((env_dir / "fastagent.config.yaml").resolve()) + str((env_dir / "fast-agent.yaml").resolve()) ) != -1 rendered = str(outcome.messages[0].text) @@ -671,7 +671,7 @@ async def test_models_aliases_unset_writes_project_target(tmp_path: Path) -> Non workspace = tmp_path / "workspace" env_dir = workspace / ".fast-agent" workspace.mkdir(parents=True) - project_config = workspace / "fastagent.config.yaml" + project_config = workspace / "fast-agent.yaml" _write_yaml( project_config, { @@ -727,7 +727,7 @@ async def test_models_aliases_set_dry_run_is_deterministic(tmp_path: Path) -> No finally: os.chdir(previous_cwd) - assert (env_dir / "fastagent.config.yaml").exists() is False + assert (env_dir / "fast-agent.yaml").exists() is False rendered = str(outcome.messages[0].text) assert "▎ model references set" in rendered @@ -809,7 +809,7 @@ async def test_models_references_follow_loaded_config_root_instead_of_cwd_overla workspace.mkdir(parents=True) _write_yaml( - parent / "fastagent.config.yaml", + parent / "fast-agent.yaml", { "model_references": { "system": { @@ -819,7 +819,7 @@ async def test_models_references_follow_loaded_config_root_instead_of_cwd_overla }, ) _write_yaml( - env_dir / "fastagent.config.yaml", + env_dir / "fast-agent.yaml", { "model_references": { "system": { @@ -830,7 +830,7 @@ async def test_models_references_follow_loaded_config_root_instead_of_cwd_overla ) settings = Settings(environment_dir=None) - settings._config_file = str(parent / "fastagent.config.yaml") + settings._config_file = str(parent / "fast-agent.yaml") previous_cwd = Path.cwd() previous_env_dir = os.environ.get("ENVIRONMENT_DIR") diff --git a/tests/unit/fast_agent/commands/test_runtime_model_picker_bootstrap.py b/tests/unit/fast_agent/commands/test_runtime_model_picker_bootstrap.py index 4f8cf0fd5..b3469a7b2 100644 --- a/tests/unit/fast_agent/commands/test_runtime_model_picker_bootstrap.py +++ b/tests/unit/fast_agent/commands/test_runtime_model_picker_bootstrap.py @@ -81,6 +81,7 @@ def _make_request( noenv=False, force_smart=False, shell_runtime=False, + no_shell=False, mode="interactive", transport="http", host="127.0.0.1", @@ -228,7 +229,7 @@ async def fake_run_model_picker_async(**kwargs): @pytest.mark.asyncio async def test_select_model_from_picker_passes_config_start_path(monkeypatch, tmp_path: Path) -> None: - config_path = tmp_path / "project" / "fastagent.config.yaml" + config_path = tmp_path / "project" / "fast-agent.yaml" config_path.parent.mkdir(parents=True) config_path.write_text("default_model: haikutiny\n", encoding="utf-8") request = _make_request(config_path=str(config_path)) @@ -378,7 +379,7 @@ def test_resolve_model_picker_initial_selection_uses_config_relative_overlay_dir workspace = tmp_path / "workspace" project_dir = workspace / "project" env_dir = project_dir / ".fast-agent" - config_path = project_dir / "fastagent.config.yaml" + config_path = project_dir / "fast-agent.yaml" overlays_dir = env_dir / "model-overlays" overlays_dir.mkdir(parents=True) config_path.parent.mkdir(parents=True, exist_ok=True) @@ -419,7 +420,7 @@ def test_load_request_settings_refreshes_stale_cached_settings(tmp_path: Path) - env_dir = workspace / ".fast-agent" workspace.mkdir(parents=True) env_dir.mkdir(parents=True) - (env_dir / "fastagent.config.yaml").write_text( + (env_dir / "fast-agent.yaml").write_text( "model_references:\n" " system:\n" " last_used: gpt-4.1-mini\n", @@ -442,7 +443,7 @@ def test_load_request_settings_refreshes_stale_cached_settings(tmp_path: Path) - config_module._settings = old_settings assert settings.model_references["system"]["last_used"] == "gpt-4.1-mini" - assert settings._config_file == str((env_dir / "fastagent.config.yaml").resolve()) + assert settings._config_file == str((env_dir / "fast-agent.yaml").resolve()) def test_persist_model_picker_last_used_selection_writes_env_overlay(tmp_path: Path) -> None: @@ -467,7 +468,7 @@ def test_persist_model_picker_last_used_selection_writes_env_overlay(tmp_path: P assert persisted is True - with open(env_dir / "fastagent.config.yaml", "r", encoding="utf-8") as handle: + with open(env_dir / "fast-agent.yaml", "r", encoding="utf-8") as handle: saved = yaml.safe_load(handle) assert saved["model_references"]["system"]["last_used"] == "gpt-4.1-mini" @@ -498,7 +499,7 @@ def test_persist_model_picker_last_used_selection_uses_request_environment_dir( assert persisted is True - with open(env_dir / "fastagent.config.yaml", "r", encoding="utf-8") as handle: + with open(env_dir / "fast-agent.yaml", "r", encoding="utf-8") as handle: saved = yaml.safe_load(handle) assert saved["model_references"]["system"]["last_used"] == "gpt-4.1-mini" @@ -511,7 +512,7 @@ def test_persist_model_picker_last_used_selection_uses_runtime_cwd_env_root( nested = workspace / "nested" workspace.mkdir(parents=True) nested.mkdir(parents=True) - (workspace / "fastagent.config.yaml").write_text("default_model: null\n", encoding="utf-8") + (workspace / "fast-agent.yaml").write_text("default_model: null\n", encoding="utf-8") previous_cwd = Path.cwd() previous_env_dir = os.environ.pop("ENVIRONMENT_DIR", None) @@ -531,9 +532,9 @@ def test_persist_model_picker_last_used_selection_uses_runtime_cwd_env_root( os.environ["ENVIRONMENT_DIR"] = previous_env_dir assert persisted is True - assert not (workspace / ".fast-agent" / "fastagent.config.yaml").exists() + assert not (workspace / ".fast-agent" / "fast-agent.yaml").exists() - with open(nested / ".fast-agent" / "fastagent.config.yaml", "r", encoding="utf-8") as handle: + with open(nested / ".fast-agent" / "fast-agent.yaml", "r", encoding="utf-8") as handle: saved = yaml.safe_load(handle) assert saved["model_references"]["system"]["last_used"] == "gpt-4.1-mini" @@ -563,7 +564,7 @@ def test_persist_model_picker_last_used_selection_creates_env_overlay_on_first_r assert persisted is True - with open(env_dir / "fastagent.config.yaml", "r", encoding="utf-8") as handle: + with open(env_dir / "fast-agent.yaml", "r", encoding="utf-8") as handle: saved = yaml.safe_load(handle) assert saved["model_references"]["system"]["last_used"] == "gpt-4.1-mini" @@ -574,7 +575,7 @@ def test_persist_model_picker_last_used_selection_updates_loaded_env_overlay_in_ ) -> None: workspace = tmp_path / "workspace" env_dir = workspace / ".fast-agent" - config_path = env_dir / "fastagent.config.yaml" + config_path = env_dir / "fast-agent.yaml" workspace.mkdir(parents=True) env_dir.mkdir(parents=True) config_path.write_text( @@ -602,7 +603,7 @@ def test_persist_model_picker_last_used_selection_updates_loaded_env_overlay_in_ os.environ["ENVIRONMENT_DIR"] = previous_env_dir assert persisted is True - assert not (env_dir / ".fast-agent" / "fastagent.config.yaml").exists() + assert not (env_dir / ".fast-agent" / "fast-agent.yaml").exists() with open(config_path, "r", encoding="utf-8") as handle: saved = yaml.safe_load(handle) @@ -632,7 +633,7 @@ def test_persist_model_picker_last_used_selection_respects_noenv(tmp_path: Path) os.chdir(previous_cwd) assert persisted is False - assert not (env_dir / "fastagent.config.yaml").exists() + assert not (env_dir / "fast-agent.yaml").exists() def test_persist_model_picker_last_used_selection_writes_explicit_config_file( @@ -643,7 +644,7 @@ def test_persist_model_picker_last_used_selection_writes_explicit_config_file( config_root.mkdir(parents=True) workspace.mkdir(parents=True) - config_path = config_root / "fastagent.config.yaml" + config_path = config_root / "fast-agent.yaml" config_path.write_text("default_model: claude-haiku-4-5\n", encoding="utf-8") request = _make_request(config_path=str(config_path)) @@ -664,7 +665,7 @@ def test_persist_model_picker_last_used_selection_writes_explicit_config_file( os.environ["ENVIRONMENT_DIR"] = previous_env_dir assert persisted is True - assert not (workspace / ".fast-agent" / "fastagent.config.yaml").exists() + assert not (workspace / ".fast-agent" / "fast-agent.yaml").exists() with open(config_path, "r", encoding="utf-8") as handle: saved = yaml.safe_load(handle) @@ -733,7 +734,7 @@ def __init__(self, *args, **kwargs) -> None: os.environ["ENVIRONMENT_DIR"] = previous_env_dir config_module._settings = old_settings - config_path = workspace / ".fast-agent" / "fastagent.config.yaml" + config_path = workspace / ".fast-agent" / "fast-agent.yaml" assert config_path.exists() with open(config_path, "r", encoding="utf-8") as handle: @@ -757,7 +758,7 @@ async def test_run_agent_request_uses_last_used_for_noninteractive_startup( workspace = tmp_path / "workspace" env_dir = workspace / ".cdx" env_dir.mkdir(parents=True) - (env_dir / "fastagent.config.yaml").write_text( + (env_dir / "fast-agent.yaml").write_text( "default_model: null\n" "model_references:\n" " system:\n" diff --git a/tests/unit/fast_agent/commands/test_runtime_request_builders.py b/tests/unit/fast_agent/commands/test_runtime_request_builders.py index dabc1a2a9..c48e7863a 100644 --- a/tests/unit/fast_agent/commands/test_runtime_request_builders.py +++ b/tests/unit/fast_agent/commands/test_runtime_request_builders.py @@ -480,6 +480,68 @@ def test_build_command_run_request_rejects_json_schema_with_multi_model() -> Non ) +def test_build_command_run_request_accepts_schema_model_for_one_shot() -> None: + request = build_command_run_request( + name="cli", + instruction_option=None, + config_path=None, + servers=None, + urls=None, + auth=None, + client_metadata_url=None, + agent_cards=None, + card_tools=None, + model=None, + message="hello", + prompt_file=None, + json_schema=None, + schema_model="tests.fixtures:Result", + result_file=None, + resume=None, + npx=None, + uvx=None, + stdio=None, + target_agent_name=None, + skills_directory=None, + environment_dir=None, + shell_enabled=False, + mode="interactive", + ) + + assert request.schema_model == "tests.fixtures:Result" + assert request.quiet is True + + +def test_build_command_run_request_rejects_json_schema_with_schema_model() -> None: + with pytest.raises(typer.BadParameter, match="Cannot combine --json-schema with --schema-model"): + build_command_run_request( + name="cli", + instruction_option=None, + config_path=None, + servers=None, + urls=None, + auth=None, + client_metadata_url=None, + agent_cards=None, + card_tools=None, + model=None, + message="hello", + prompt_file=None, + json_schema="schema.json", + schema_model="tests.fixtures:Result", + result_file=None, + resume=None, + npx=None, + uvx=None, + stdio=None, + target_agent_name=None, + skills_directory=None, + environment_dir=None, + shell_enabled=False, + mode="interactive", + ) + + def test_resolve_smart_agent_enabled_disables_smart_for_multi_model_even_when_forced() -> None: assert resolve_smart_agent_enabled( "gpt-4.1,claude-sonnet-4-5", @@ -701,6 +763,65 @@ def test_build_command_run_request_rejects_noenv_with_resume() -> None: ) +def test_build_command_run_request_rejects_shell_with_no_shell() -> None: + with pytest.raises(typer.BadParameter, match="Cannot combine --shell with --no-shell"): + build_command_run_request( + name="cli", + instruction_option=None, + config_path=None, + servers=None, + urls=None, + auth=None, + client_metadata_url=None, + agent_cards=None, + card_tools=None, + model=None, + message=None, + prompt_file=None, + result_file=None, + resume=None, + npx=None, + uvx=None, + stdio=None, + target_agent_name=None, + skills_directory=None, + environment_dir=None, + shell_enabled=True, + no_shell=True, + mode="interactive", + ) + + +def test_build_command_run_request_propagates_no_shell() -> None: + request = build_command_run_request( + name="cli", + instruction_option=None, + config_path=None, + servers=None, + urls=None, + auth=None, + client_metadata_url=None, + agent_cards=None, + card_tools=None, + model=None, + message=None, + prompt_file=None, + result_file=None, + resume=None, + npx=None, + uvx=None, + stdio=None, + target_agent_name=None, + skills_directory=None, + environment_dir=None, + shell_enabled=False, + no_shell=True, + mode="interactive", + ) + + assert request.no_shell is True + + def test_build_command_run_request_rejects_malformed_url() -> None: with pytest.raises(typer.BadParameter, match="URL must have http or https scheme"): build_command_run_request( diff --git a/tests/unit/fast_agent/commands/test_runtime_result_export.py b/tests/unit/fast_agent/commands/test_runtime_result_export.py index bb0db5b2d..ada387e27 100644 --- a/tests/unit/fast_agent/commands/test_runtime_result_export.py +++ b/tests/unit/fast_agent/commands/test_runtime_result_export.py @@ -130,6 +130,7 @@ def _make_request( noenv=False, force_smart=False, shell_runtime=False, + no_shell=False, mode="interactive", transport="http", host="127.0.0.1", @@ -490,7 +491,7 @@ async def test_run_single_agent_cli_flow_json_schema_invalid_output_exits_nonzer assert exc_info.value.exit_code == 1 captured = capsys.readouterr() assert captured.out == "" - assert "valid JSON matching --json-schema" in captured.err + assert "valid JSON matching the structured output schema" in captured.err @pytest.mark.asyncio @@ -606,9 +607,9 @@ def _capture_markdown_notice(text: str, **kwargs: str | None) -> None: await _resume_session_if_requested(app, request) assert any("Resumed session" in notice for notice in plain_notices) - assert any("Last assistant message" in notice for notice in plain_notices) assert markdown_notices assert markdown_notices[0][0] == "## Welcome back\n\n- item" + assert markdown_notices[0][1]["title"] == "Last assistant message" @pytest.mark.asyncio diff --git a/tests/unit/fast_agent/commands/test_setup_quickstart.py b/tests/unit/fast_agent/commands/test_setup_quickstart.py new file mode 100644 index 000000000..909c043fc --- /dev/null +++ b/tests/unit/fast_agent/commands/test_setup_quickstart.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from typer.testing import CliRunner + +from fast_agent.cli.commands import quickstart, setup + +if TYPE_CHECKING: + from pytest import MonkeyPatch + + +def test_setup_creates_preferred_config_and_secrets_filenames(tmp_path: Path) -> None: + target = tmp_path / "app" + + result = CliRunner().invoke( + setup.app, + ["--config-dir", str(target), "--force"], + input="y\ny\n", + ) + + assert result.exit_code == 0, result.output + assert (target / "fast-agent.yaml").exists() + assert (target / "fast-agent.secrets.yaml").exists() + assert not (target / "fastagent.config.yaml").exists() + assert not (target / "fastagent.secrets.yaml").exists() + assert "fast-agent.yaml" in result.output + assert "fast-agent.secrets.yaml" in result.output + assert "Created fast-agent home:" in result.output + assert "Created config file:" in result.output + assert "Created secrets file:" in result.output + assert "fastagent.config.yaml" not in result.output + + +def test_quickstart_copies_preferred_config_filename( + monkeypatch: MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(quickstart, "BASE_EXAMPLES_DIR", Path("examples").resolve()) + + created = quickstart.copy_example_files("workflow", tmp_path, force=True) + + assert "workflow/fast-agent.yaml" in created + assert (tmp_path / "workflow" / "fast-agent.yaml").exists() + assert not (tmp_path / "workflow" / "fastagent.config.yaml").exists() diff --git a/tests/unit/fast_agent/commands/test_shell_cwd_policy.py b/tests/unit/fast_agent/commands/test_shell_cwd_policy.py index 50ba586a2..b2890cc14 100644 --- a/tests/unit/fast_agent/commands/test_shell_cwd_policy.py +++ b/tests/unit/fast_agent/commands/test_shell_cwd_policy.py @@ -90,6 +90,40 @@ def test_collect_shell_cwd_issues_respects_shell_flag_request(tmp_path: Path) -> assert with_flag[0].kind == "missing" +def test_collect_shell_cwd_issues_respects_no_shell(tmp_path: Path) -> None: + agents: dict[str, "AgentCardData"] = { + "shell": { + "config": AgentConfig( + name="shell", + instruction="x", + servers=[], + shell=True, + cwd=Path("missing"), + ) + }, + "skill": { + "config": AgentConfig( + name="skill", + instruction="x", + servers=[], + shell=False, + cwd=Path("missing-skill"), + ) + }, + } + agents["skill"]["config"].skill_manifests = [object()] # type: ignore[list-item] + + assert ( + collect_shell_cwd_issues( + agents, + shell_runtime_requested=True, + no_shell=True, + cwd=tmp_path, + ) + == [] + ) + + def test_collect_shell_cwd_issues_from_runtime_agents(tmp_path: Path) -> None: class RuntimeAgent: def __init__(self, shell_runtime_enabled: bool, cwd: Path | None) -> None: diff --git a/tests/unit/fast_agent/commands/test_tool_summaries.py b/tests/unit/fast_agent/commands/test_tool_summaries.py index 95162ed45..554f11060 100644 --- a/tests/unit/fast_agent/commands/test_tool_summaries.py +++ b/tests/unit/fast_agent/commands/test_tool_summaries.py @@ -85,4 +85,16 @@ def test_build_tool_summaries_marks_smart_skybridge_tools() -> None: [_tool("smart_with_resource", meta={"openai/skybridgeEnabled": True})], ) - assert summaries[0].suffix == "(Smart) (skybridge)" + assert summaries[0].suffix == "(Smart) (Apps SDK)" + + +def test_build_tool_summaries_marks_mcp_app_tools() -> None: + agent = _AgentStub() + + summaries = build_tool_summaries( + agent, + [_tool("app_tool", meta={"ui/appEnabled": True, "ui/appTemplate": "ui://app"})], + ) + + assert summaries[0].suffix == "(MCP App)" + assert summaries[0].template == "ui://app" diff --git a/tests/unit/fast_agent/commands/test_update_check.py b/tests/unit/fast_agent/commands/test_update_check.py index 6a80f4bce..adb4381a4 100644 --- a/tests/unit/fast_agent/commands/test_update_check.py +++ b/tests/unit/fast_agent/commands/test_update_check.py @@ -44,6 +44,7 @@ def test_resolve_update_check_marker_path_uses_configured_environment_dir( ) -> None: previous_settings = get_settings() monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) update_global_settings(Settings(environment_dir=".dev")) (tmp_path / ".dev").mkdir() diff --git a/tests/unit/fast_agent/core/test_agent_card_loader.py b/tests/unit/fast_agent/core/test_agent_card_loader.py index 838ce25f1..4bec3e838 100644 --- a/tests/unit/fast_agent/core/test_agent_card_loader.py +++ b/tests/unit/fast_agent/core/test_agent_card_loader.py @@ -430,3 +430,54 @@ def test_dump_agent_card_preserves_tool_input_schema(tmp_path: Path) -> None: assert "tool_input_schema:" in dumped assert "query:" in dumped assert "required:" in dumped + + +def test_load_agent_card_parses_plugin_command_actions(tmp_path: Path) -> None: + card_path = tmp_path / "agent.yaml" + card_path.write_text( + "\n".join( + [ + "name: command_agent", + "commands:", + " draft-next:", + " description: Draft the next user message", + " input_hint: \"[format]\"", + " handler: \"commands.py:draft_next\"", + " key: \"c-x d\"", + ] + ), + encoding="utf-8", + ) + + loaded = load_agent_cards(card_path) + commands = loaded[0].agent_data["config"].commands + + assert commands is not None + assert commands["draft-next"].description == "Draft the next user message" + assert commands["draft-next"].handler == "commands.py:draft_next" + assert commands["draft-next"].input_hint == "[format]" + assert commands["draft-next"].key == "c-x d" + + +def test_dump_agent_card_preserves_plugin_command_actions(tmp_path: Path) -> None: + card_path = tmp_path / "agent.yaml" + card_path.write_text( + "\n".join( + [ + "name: command_agent", + "commands:", + " review-last:", + " description: Review the last response", + " handler: \"commands.py:review_last\"", + ] + ), + encoding="utf-8", + ) + + loaded = load_agent_cards(card_path) + dumped = dump_agent_to_string("command_agent", loaded[0].agent_data, as_yaml=True) + + assert "commands:" in dumped + assert "review-last:" in dumped + assert "description: Review the last response" in dumped + assert "handler: commands.py:review_last" in dumped diff --git a/tests/unit/fast_agent/core/test_instruction_refresh.py b/tests/unit/fast_agent/core/test_instruction_refresh.py index 45fb30947..9f1ebcfef 100644 --- a/tests/unit/fast_agent/core/test_instruction_refresh.py +++ b/tests/unit/fast_agent/core/test_instruction_refresh.py @@ -11,6 +11,10 @@ rebuild_agent_instruction, resolve_instruction_skill_manifests, ) +from fast_agent.core.instruction_utils import ( + InstructionContextAgent, + build_agent_instruction_context, +) from fast_agent.skills import SKILLS_DEFAULT if TYPE_CHECKING: @@ -147,6 +151,28 @@ def test_build_instruction_with_context() -> None: assert result == "Root: /test/path" +def test_build_instruction_with_model_specific_context() -> None: + agent = SimpleNamespace( + name="gpt-agent", + instruction="", + agent_type="basic", + config=SimpleNamespace( + name="gpt-agent", + agent_type="basic", + source_path=None, + model="gpt-5.4", + ), + ) + context = build_agent_instruction_context(cast("InstructionContextAgent", agent)) + + result = asyncio.run( + build_instruction("Base\n{{model_specific}}", context=context) + ) + + assert "Before making tool calls, send a brief preamble" in result + assert "{{model_specific}}" not in result + + def test_build_instruction_with_aggregator() -> None: template = "{{serverInstructions}}" aggregator = StubAggregator({"my-server": ("Be helpful", ["do_thing"])}) diff --git a/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py b/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py index ae6c021bb..0d5dc9669 100644 --- a/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py +++ b/tests/unit/fast_agent/llm/provider/anthropic/test_reasoning_defaults.py @@ -64,6 +64,10 @@ class _StructuredResponse(BaseModel): answer: str +class _StructuredResponseWithMap(BaseModel): + metadata: dict[str, str] + + def test_opus_46_uses_adaptive_thinking_by_default(): llm = _make_llm("claude-opus-4-6") @@ -341,6 +345,24 @@ def test_json_structured_output_uses_output_config_format(): assert "schema" in args["output_config"]["format"] +def test_json_structured_output_sanitizes_map_additional_properties(): + llm = _make_llm("claude-opus-4-6", reasoning=False) + + args, _ = llm._build_anthropic_base_args( + model="claude-opus-4-6", + messages=[], + params=RequestParams(maxTokens=1024), + history=None, + current_extended=None, + request_tools=[], + structured_mode="json", + structured_model=_StructuredResponseWithMap, + ) + + metadata_schema = args["output_config"]["format"]["schema"]["properties"]["metadata"] + assert metadata_schema["additionalProperties"] is False + + def test_auto_structured_output_mode_prefers_json_when_direct_beta_supported(): llm = _make_llm("claude-opus-4-6", reasoning=False) @@ -456,6 +478,39 @@ def test_json_structured_output_uses_raw_schema_when_supplied() -> None: assert args["output_config"]["format"]["schema"] == schema +def test_json_structured_output_transforms_raw_schema_with_anthropic_sdk() -> None: + llm = _make_llm("claude-opus-4-6", reasoning=False) + schema = { + "type": "object", + "properties": { + "answer": { + "type": "string", + "minLength": 3, + } + }, + "required": ["answer"], + } + + args, _ = llm._build_anthropic_base_args( + model="claude-opus-4-6", + messages=[], + params=RequestParams(maxTokens=1024, structured_schema=schema), + history=None, + current_extended=None, + request_tools=[], + structured_mode="json", + structured_model=None, + structured_schema=schema, + ) + + transformed = args["output_config"]["format"]["schema"] + answer_schema = transformed["properties"]["answer"] + assert transformed["additionalProperties"] is False + assert "minLength" not in answer_schema + assert "minLength: 3" in answer_schema["description"] + assert schema["properties"]["answer"]["minLength"] == 3 + + @pytest.mark.asyncio async def test_json_structured_output_preserves_regular_tools() -> None: llm = _make_llm("claude-opus-4-6", reasoning=False) @@ -501,7 +556,7 @@ def test_structured_schema_with_tools_is_deferred_until_tool_result() -> None: description="Return the probe payload for validation.", inputSchema={"type": "object", "properties": {}}, ) - params = RequestParams(structured_schema=schema) + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") _, prepared_params = llm._prepare_structured_request( [Prompt.user("call the tool, then return json")], @@ -513,6 +568,34 @@ def test_structured_schema_with_tools_is_deferred_until_tool_result() -> None: assert prepared_params.structured_schema is None +def test_structured_schema_with_no_tools_policy_preserves_schema_for_tool_suppression() -> None: + llm = _make_llm("claude-sonnet-4-6", reasoning=False) + schema = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + } + tool = Tool( + name="lookup_probe_payload", + description="Return the probe payload for validation.", + inputSchema={"type": "object", "properties": {}}, + ) + params = RequestParams(structured_schema=schema, structured_tool_policy="no_tools") + + _, prepared_params = llm._prepare_structured_request( + [Prompt.user("return json without calling tools")], + params, + [tool], + ) + + assert prepared_params.structured_schema == schema + assert llm._should_suppress_tools_for_structured_final( + [Prompt.user("return json without calling tools")], + prepared_params, + [tool], + ) + + @pytest.mark.asyncio async def test_tool_use_structured_output_uses_raw_schema_when_supplied() -> None: llm = _make_llm("claude-opus-4-6", reasoning=False) @@ -541,6 +624,43 @@ async def test_tool_use_structured_output_uses_raw_schema_when_supplied() -> Non assert normalized_answer_schema.get("type") == "string" +@pytest.mark.asyncio +async def test_tool_use_structured_output_sanitizes_raw_schema_for_anthropic() -> None: + llm = _make_llm("claude-opus-4-6", reasoning=False) + schema = { + "type": "object", + "properties": { + "answer": {"type": "string"}, + "context": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + }, + }, + "required": ["answer"], + } + + tools = await llm._prepare_tools( + "claude-opus-4-6", + structured_model=None, + structured_schema=schema, + tools=None, + structured_mode="tool_use", + ) + + input_schema = tools[0]["input_schema"] + assert isinstance(input_schema, dict) + normalized_input_schema = {str(key): value for key, value in input_schema.items()} + assert normalized_input_schema["additionalProperties"] is False + assert normalized_input_schema["required"] == ["answer"] + properties = normalized_input_schema["properties"] + assert isinstance(properties, dict) + normalized_properties = {str(key): value for key, value in properties.items()} + context_schema = normalized_properties["context"] + assert isinstance(context_schema, dict) + assert "default" not in context_schema + assert schema["properties"]["context"]["default"] is None + + @pytest.mark.asyncio async def test_tool_use_structured_schema_response_is_finalized_without_model() -> None: llm = _make_llm("claude-sonnet-4-6", reasoning=False) diff --git a/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py b/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py index acbf81442..f51974ad5 100644 --- a/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py +++ b/tests/unit/fast_agent/llm/providers/test_bedrock_converter.py @@ -1,9 +1,11 @@ +import pytest from mcp import Tool from mcp.types import CallToolRequest, CallToolRequestParams, ListToolsResult from fast_agent.llm.provider.bedrock.llm_bedrock import BedrockLLM from fast_agent.llm.provider.bedrock.multipart_converter_bedrock import BedrockConverter -from fast_agent.types import PromptMessageExtended +from fast_agent.mcp.prompt import Prompt +from fast_agent.types import PromptMessageExtended, RequestParams def test_bedrock_converter_emits_tool_use_items(): @@ -55,3 +57,112 @@ def test_resolve_tool_use_name_uses_mapped_name(): ) assert resolved == "my_tool" + + +@pytest.mark.asyncio +async def test_bedrock_structured_schema_path_preserves_tools(monkeypatch): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + tool = Tool( + name="lookup", + description="Lookup data.", + inputSchema={"type": "object", "properties": {}}, + ) + captured_tools = None + + llm = object.__new__(BedrockLLM) + llm.default_request_params = RequestParams(model="amazon.nova-lite-v1:0") + + async def structured_schema( + multipart_messages, + schema_arg, + request_params=None, + tools=None, + ): + nonlocal captured_tools + del multipart_messages, schema_arg, request_params + captured_tools = tools + return None, Prompt.assistant("ok") + + monkeypatch.setattr( + llm, + "_apply_prompt_provider_specific_structured_schema", + structured_schema, + ) + + result = await BedrockLLM._apply_prompt_provider_specific( + llm, + [Prompt.user("call the tool")], + RequestParams(structured_schema=schema), + [tool], + ) + + assert result.last_text() == "ok" + assert captured_tools == [tool] + + +@pytest.mark.asyncio +async def test_bedrock_structured_schema_prompt_preserves_history_and_tool_context(monkeypatch): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + tool = Tool( + name="lookup", + description="Lookup data.", + inputSchema={"type": "object", "properties": {}}, + ) + tool_call = CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name="lookup", arguments={"query": "answer"}), + ) + messages = [ + Prompt.user("Use the lookup tool."), + PromptMessageExtended(role="assistant", content=[], tool_calls={"call_1": tool_call}), + Prompt.user("Tool result: answer is 42."), + ] + captured_messages = None + captured_params = None + captured_tools = None + + llm = object.__new__(BedrockLLM) + llm.default_request_params = RequestParams(model="amazon.nova-lite-v1:0") + llm.capabilities = {} + llm._reasoning_effort = None + llm._reasoning_effort_spec = None + + async def apply_prompt(multipart_messages, request_params=None, tools=None): + nonlocal captured_messages, captured_params, captured_tools + captured_messages = multipart_messages + captured_params = request_params + captured_tools = tools + return Prompt.assistant('{"value":"42"}') + + monkeypatch.setattr(llm, "_apply_prompt_provider_specific", apply_prompt) + + parsed, response = await BedrockLLM._apply_prompt_provider_specific_structured_schema( + llm, + messages, + schema, + RequestParams(structured_schema=schema), + [tool], + ) + + assert parsed == {"value": "42"} + assert response.last_text() == '{"value":"42"}' + assert captured_messages is not None + assert [message.role for message in captured_messages] == ["user", "assistant", "user"] + assert captured_messages[1].tool_calls == {"call_1": tool_call} + final_text = "\n".join( + block.text for block in captured_messages[-1].content if block.type == "text" + ) + assert "Tool result: answer is 42." in final_text + assert "JSON Schema:" in final_text + assert messages[-1].last_text() == "Tool result: answer is 42." + assert captured_params is not None + assert captured_params.structured_schema is None + assert captured_tools == [tool] diff --git a/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py b/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py index 72ef53efb..50dab15e7 100644 --- a/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py +++ b/tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py @@ -1,5 +1,5 @@ import types -from typing import cast +from typing import TYPE_CHECKING, cast import pytest from google.genai import types as google_types @@ -12,13 +12,16 @@ from fast_agent.mcp.prompt import Prompt from fast_agent.types import RequestParams +if TYPE_CHECKING: + from fast_agent.llm.request_params import StructuredToolPolicy + def _build_llm(config: Settings) -> GoogleNativeLLM: """Create a Google LLM instance with the provided config.""" return GoogleNativeLLM(context=Context(config=config)) -def test_vertex_cfg_accepts_model_object_and_resolves_preview_names() -> None: +def test_vertex_cfg_accepts_model_object_and_expands_model_names() -> None: """Vertex config may arrive as a pydantic model with a custom attr object.""" google_settings = GoogleSettings() setattr( @@ -35,7 +38,7 @@ def test_vertex_cfg_accepts_model_object_and_resolves_preview_names() -> None: assert project_id == "proj" assert location == "loc" - resolved = llm._resolve_model_name("gemini-2.5-flash-preview-09-2025") + resolved = llm._resolve_model_name("gemini-2.5-flash") assert ( resolved == "projects/proj/locations/loc/publishers/google/models/gemini-2.5-flash" @@ -63,8 +66,8 @@ def test_vertex_cfg_accepts_dict_and_provider_key_manager_allows_adc() -> None: assert project_id == "proj" assert location == "europe-west4" - resolved = llm._resolve_model_name("gemini-2.5-flash-preview-09-2025") - assert resolved.endswith("gemini-2.5-flash") + resolved = llm._resolve_model_name("gemini-3-flash-preview") + assert resolved.endswith("gemini-3-flash-preview") assert resolved.startswith( "projects/proj/locations/europe-west4/publishers/google/models/" ) @@ -162,7 +165,7 @@ def test_structured_schema_with_tools_is_deferred_until_tool_result() -> None: description="Return the probe payload for validation.", inputSchema={"type": "object", "properties": {}}, ) - params = RequestParams(structured_schema=schema) + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") _, prepared_params = llm._prepare_structured_request( [Prompt.user("call the tool, then return json")], @@ -175,7 +178,18 @@ def test_structured_schema_with_tools_is_deferred_until_tool_result() -> None: @pytest.mark.asyncio -async def test_structured_schema_in_generate_path_uses_google_response_schema() -> None: +@pytest.mark.parametrize( + ("policy", "expected_tools"), + [ + ("auto", True), + ("always", True), + ("no_tools", False), + ], +) +@pytest.mark.asyncio +async def test_structured_schema_in_generate_path_can_keep_google_tools( + policy: str, expected_tools: bool +) -> None: schema = { "type": "object", "properties": {"answer": {"type": "string"}}, @@ -224,6 +238,7 @@ def _initialize_google_client(self): request_params=RequestParams( model="gemini-2.0-flash", structured_schema=schema, + structured_tool_policy=cast("StructuredToolPolicy", policy), ), tools=[ Tool( @@ -237,5 +252,77 @@ def _initialize_google_client(self): config = cast("google_types.GenerateContentConfig", captured["config"]) assert config.response_mime_type == "application/json" assert config.response_schema is not None - assert config.tools is None + assert bool(config.tools) is expected_tools assert response.last_text() == '{"answer":"ok"}' + + +@pytest.mark.asyncio +async def test_structured_schema_in_generate_path_returns_google_tool_calls() -> None: + schema = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + } + + class FakeModels: + async def generate_content(self, **kwargs): + return google_types.GenerateContentResponse.model_validate( + { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { + "function_call": { + "name": "lookup_probe_payload", + "args": {}, + } + } + ], + }, + "finish_reason": "STOP", + } + ] + } + ) + + class FakeAio: + def __init__(self) -> None: + self.models = FakeModels() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + class FakeClient: + def __init__(self) -> None: + self.aio = FakeAio() + + class Harness(GoogleNativeLLM): + def _initialize_google_client(self): + return FakeClient() + + llm = Harness(context=Context(config=Settings()), model="gemini-2.0-flash") + response = await llm._google_completion( + [google_types.Content(role="user", parts=[google_types.Part.from_text(text="answer")])], + request_params=RequestParams( + model="gemini-2.0-flash", + structured_schema=schema, + structured_tool_policy="always", + ), + tools=[ + Tool( + name="lookup_probe_payload", + description="Return the probe payload for validation.", + inputSchema={"type": "object", "properties": {}}, + ) + ], + ) + + assert response.tool_calls + [tool_call] = response.tool_calls.values() + assert tool_call.params.name == "lookup_probe_payload" + assert response.stop_reason == "toolUse" diff --git a/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py b/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py index fa9072c75..b01e6f474 100644 --- a/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py +++ b/tests/unit/fast_agent/llm/providers/test_llm_openai_history.py @@ -79,7 +79,7 @@ async def test_apply_prompt_converts_last_message_when_history_disabled(): def test_reasoning_content_injected_for_reasoning_content_models(): """Ensure reasoning_content channel is forwarded for models that support it.""" context = Context() - llm = OpenAILLM(context=context, model="moonshotai/kimi-k2-thinking") + llm = OpenAILLM(context=context, model="zai-org/glm-5.1") reasoning_text = "deliberate steps" msg = PromptMessageExtended( @@ -99,7 +99,7 @@ def test_reasoning_content_injected_for_reasoning_content_models(): def test_reasoning_content_preserved_with_tool_calls(): """Reasoning content should ride along even when assistant is calling tools.""" context = Context() - llm = OpenAILLM(context=context, model="moonshotai/kimi-k2-thinking") + llm = OpenAILLM(context=context, model="zai-org/glm-5.1") tool_call = CallToolRequest( method="tools/call", diff --git a/tests/unit/fast_agent/llm/providers/test_openai_schema_sanitizer.py b/tests/unit/fast_agent/llm/providers/test_openai_schema_sanitizer.py index b605c305e..120d05c21 100644 --- a/tests/unit/fast_agent/llm/providers/test_openai_schema_sanitizer.py +++ b/tests/unit/fast_agent/llm/providers/test_openai_schema_sanitizer.py @@ -1,5 +1,6 @@ from fast_agent.llm.provider.openai.llm_openai import OpenAILLM from fast_agent.llm.provider.openai.schema_sanitizer import ( + sanitize_response_format_schema, sanitize_tool_input_schema, should_strip_tool_schema_defaults, ) @@ -37,6 +38,49 @@ def test_sanitize_tool_input_schema_removes_default_recursively() -> None: assert seed_schema["type"] == "integer" +def test_sanitize_response_format_schema_requires_all_properties() -> None: + schema = { + "type": "object", + "properties": { + "value": {"type": "string"}, + "context": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + }, + }, + "required": ["value"], + } + + sanitized = sanitize_response_format_schema(schema) + + assert sanitized["required"] == ["value", "context"] + assert sanitized["additionalProperties"] is False + assert "default" not in sanitized["properties"]["context"] + + +def test_openai_response_format_uses_strict_schema_for_raw_structured_schema() -> None: + schema = { + "type": "object", + "properties": { + "value": {"type": "string"}, + "context": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + }, + }, + "required": ["value"], + } + + llm = OpenAILLM(Provider.OPENAI, model="gpt-5-mini") + response_format = llm.schema_to_response_format(schema) + strict_schema = response_format["json_schema"]["schema"] + + assert strict_schema["required"] == ["value", "context"] + assert strict_schema["additionalProperties"] is False + assert "default" not in strict_schema["properties"]["context"] + assert schema["required"] == ["value"] + + def test_should_strip_tool_schema_defaults_known_kimi_variants() -> None: assert should_strip_tool_schema_defaults("kimi25") assert should_strip_tool_schema_defaults("kimi26") diff --git a/tests/unit/fast_agent/llm/providers/test_responses_helpers.py b/tests/unit/fast_agent/llm/providers/test_responses_helpers.py index 14241d9af..6d8311548 100644 --- a/tests/unit/fast_agent/llm/providers/test_responses_helpers.py +++ b/tests/unit/fast_agent/llm/providers/test_responses_helpers.py @@ -933,6 +933,65 @@ def test_extract_raw_assistant_message_items_preserves_phase_metadata() -> None: assert payload["content"][0]["text"] == "Let me inspect that first." +def test_extract_raw_assistant_message_items_preserves_unphased_messages() -> None: + harness = _OutputHarness() + response = SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + id="msg_123", + role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text="Checking the repo.")], + ) + ] + ) + + raw_items, message_phase = harness._extract_raw_assistant_message_items(response) + + assert message_phase is None + assert len(raw_items) == 1 + assert isinstance(raw_items[0], TextContent) + payload = json.loads(raw_items[0].text) + assert payload["type"] == "message" + assert "phase" not in payload + assert payload["content"][0]["text"] == "Checking the repo." + + +def test_extract_raw_assistant_message_items_keeps_mixed_phase_items() -> None: + harness = _OutputHarness() + response = SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + id="msg_123", + role="assistant", + status="completed", + phase="commentary", + content=[SimpleNamespace(type="output_text", text="Checking the repo.")], + ), + SimpleNamespace( + type="message", + id="msg_124", + role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text="Running commands.")], + ), + ] + ) + + raw_items, message_phase = harness._extract_raw_assistant_message_items(response) + + assert message_phase is None + assert len(raw_items) == 2 + assert isinstance(raw_items[0], TextContent) + assert isinstance(raw_items[1], TextContent) + first_payload = json.loads(raw_items[0].text) + second_payload = json.loads(raw_items[1].text) + assert first_payload["phase"] == "commentary" + assert "phase" not in second_payload + + def test_convert_extended_messages_to_provider_includes_assistant_phase() -> None: harness = _ContentHarness() message = PromptMessageExtended( diff --git a/tests/unit/fast_agent/llm/providers/test_xai_responses.py b/tests/unit/fast_agent/llm/providers/test_xai_responses.py new file mode 100644 index 000000000..564bd9a02 --- /dev/null +++ b/tests/unit/fast_agent/llm/providers/test_xai_responses.py @@ -0,0 +1,117 @@ +from fast_agent.config import Settings, XAIResponsesSettings, XAISettings +from fast_agent.context import Context +from fast_agent.llm.provider.openai.responses_websocket import ( + StatelessResponsesWsPlanner, + resolve_responses_ws_url, +) +from fast_agent.llm.provider.openai.xai_responses import ( + DEFAULT_XAI_RESPONSES_MODEL, + XAIResponsesLLM, +) +from fast_agent.llm.provider_types import Provider + + +def test_xairesponses_provider_defaults_to_sse_transport() -> None: + llm = XAIResponsesLLM( + context=Context(config=Settings(xai=XAISettings(api_key="test-key"))), + model="grok-4.3", + ) + + assert llm.provider == Provider.XAI + assert llm.configured_transport == "sse" + + +def test_xairesponses_alias_remains_supported() -> None: + llm = XAIResponsesLLM( + provider=Provider.XAI_RESPONSES, + context=Context(config=Settings(xairesponses=XAIResponsesSettings(api_key="test-key"))), + model="grok-4.3", + ) + + assert llm.provider == Provider.XAI_RESPONSES + assert llm._api_key() == "test-key" + + +def test_xairesponses_default_model_used_when_model_missing() -> None: + llm = XAIResponsesLLM( + context=Context(config=Settings(xai=XAISettings(api_key="test-key"))), + model="", + ) + + assert llm.default_request_params.model == DEFAULT_XAI_RESPONSES_MODEL + + +def test_xairesponses_uses_xai_config_fallback() -> None: + settings = Settings( + xai=XAISettings( + api_key="xai-key", + base_url="https://gateway.example/xai/v1", + default_headers={"X-Test": "1"}, + default_model="grok-4", + ) + ) + llm = XAIResponsesLLM(context=Context(config=settings), model="") + + assert llm._api_key() == "xai-key" + assert llm._base_url() == "https://gateway.example/xai/v1" + assert llm._default_headers() == {"X-Test": "1"} + assert llm.default_request_params.model == "grok-4" + + +def test_xairesponses_websocket_url_uses_responses_endpoint() -> None: + assert resolve_responses_ws_url("https://api.x.ai/v1") == "wss://api.x.ai/v1/responses" + + +def test_xairesponses_websocket_headers_are_not_openai_beta_headers() -> None: + llm = XAIResponsesLLM( + context=Context( + config=Settings( + xai=XAISettings( + api_key="test-key", + default_headers={"X-Test": "1"}, + ) + ) + ), + model="grok-4.3", + ) + + headers = llm._build_websocket_headers() + + assert headers["Authorization"] == "Bearer test-key" + assert headers["X-Test"] == "1" + assert "OpenAI-Beta" not in headers + + +def test_xairesponses_uses_stateless_websocket_planner() -> None: + llm = XAIResponsesLLM( + context=Context(config=Settings(xai=XAISettings(api_key="test-key"))), + model="grok-4.3", + ) + + assert isinstance(llm._new_ws_request_planner(), StatelessResponsesWsPlanner) + + +def test_xairesponses_builds_conservative_response_payload() -> None: + llm = XAIResponsesLLM( + context=Context(config=Settings(xai=XAISettings(api_key="test-key"))), + model="grok-4.3", + ) + input_items = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + } + ] + + args = llm._build_response_args(input_items, llm.default_request_params, tools=None) + + assert args["model"] == "grok-4.3" + assert args["store"] is False + assert args["input"] == input_items + assert args["parallel_tool_calls"] is False + assert "include" not in args + assert "reasoning" not in args + assert "service_tier" not in args + assert "stream" not in args + assert "background" not in args diff --git a/tests/unit/fast_agent/llm/test_model_database.py b/tests/unit/fast_agent/llm/test_model_database.py index be406bb8c..4f043fb5e 100644 --- a/tests/unit/fast_agent/llm/test_model_database.py +++ b/tests/unit/fast_agent/llm/test_model_database.py @@ -38,6 +38,7 @@ def test_model_database_context_windows(): assert ModelDatabase.get_context_window("gemini-2.0-flash") == 1048576 assert ModelDatabase.get_context_window("Qwen/Qwen3.5-397B-A17B") == 262144 assert ModelDatabase.get_context_window("moonshotai/Kimi-K2.6") == 262144 + assert ModelDatabase.get_context_window("deepseek-ai/DeepSeek-V4-Pro") == 1_048_576 # Test unknown model assert ModelDatabase.get_context_window("unknown-model") is None @@ -82,10 +83,83 @@ def test_model_database_default_provider_lookup(): assert ModelDatabase.get_default_provider("claude-sonnet-4-6") == Provider.ANTHROPIC assert ModelDatabase.get_default_provider("openai.gpt-4.1") == Provider.OPENAI assert ModelDatabase.get_default_provider("gpt-5?reasoning=low") == Provider.RESPONSES + assert ModelDatabase.get_default_provider("moonshotai/kimi-k2") == Provider.HUGGINGFACE + assert ( + ModelDatabase.get_default_provider("moonshotai/kimi-k2-instruct-0905") + == Provider.HUGGINGFACE + ) assert ModelDatabase.get_default_provider("Qwen/Qwen3.5-397B-A17B") == Provider.HUGGINGFACE assert ModelDatabase.get_default_provider("unknown-model") is None +def test_anthropic_catalog_keeps_current_and_vertex_legacy_models() -> None: + assert ModelDatabase.get_model_params("claude-opus-4-7") is not None + assert ModelDatabase.get_model_params("claude-opus-4-6") is not None + assert ModelDatabase.get_model_params("claude-sonnet-4-6") is not None + assert ModelDatabase.get_model_params("claude-haiku-4-5") is not None + + assert ModelDatabase.get_model_params("claude-3-5-haiku-latest") is not None + assert ModelDatabase.get_model_params("claude-sonnet-4-20250514") is not None + assert ModelDatabase.get_model_params("claude-opus-4-20250514") is not None + + assert ModelDatabase.get_model_params("claude-3-haiku-20240307") is None + assert ModelDatabase.get_model_params("claude-3-5-sonnet-20241022") is None + assert ModelDatabase.get_model_params("claude-3-7-sonnet-20250219") is None + + +def _google_native_catalog_entries() -> list[tuple[str, ModelParameters]]: + entries: list[tuple[str, ModelParameters]] = [] + for model in ModelDatabase.list_models(): + if not model.startswith("gemini-"): + continue + if ModelDatabase.get_default_provider(model) != Provider.GOOGLE: + continue + params = ModelDatabase.get_model_params(model, provider=Provider.GOOGLE) + assert params is not None + entries.append((model, params)) + return entries + + +def test_google_native_catalog_uses_schema_mode() -> None: + gemini_entries = _google_native_catalog_entries() + + assert gemini_entries + assert {params.json_mode for _, params in gemini_entries} == {"schema"} + + +def test_google_native_catalog_has_no_gemini_25_preview_entries() -> None: + gemini_models = {model for model, _ in _google_native_catalog_entries()} + + assert {"gemini-2.5-flash", "gemini-2.5-pro"} <= gemini_models + assert not { + model + for model in gemini_models + if model.startswith("gemini-2.5-") and "preview" in model + } + + +def test_google_native_schema_tool_policy_keeps_tools_by_default() -> None: + policies = {params.structured_tool_policy for _, params in _google_native_catalog_entries()} + + assert policies == {None} + + +def test_huggingface_qwen35_uses_schema_mode_without_tools_by_default() -> None: + params = ModelDatabase.get_model_params("Qwen/Qwen3.5-397B-A17B") + + assert params is not None + assert params.json_mode == "schema" + assert params.structured_tool_policy == "no_tools" + + +def test_huggingface_kimi25_uses_schema_mode() -> None: + params = ModelDatabase.get_model_params("moonshotai/Kimi-K2.5") + + assert params is not None + assert params.json_mode == "schema" + assert params.structured_tool_policy is None + + def test_model_database_anthropic_web_tool_versions_for_46_models(): assert ModelDatabase.get_anthropic_web_search_version("claude-opus-4-6") == "web_search_20260209" assert ModelDatabase.get_anthropic_web_fetch_version("claude-opus-4-6") == "web_fetch_20260209" @@ -245,6 +319,35 @@ def test_model_database_supports_mime_basic(): assert ModelDatabase.supports_mime("gpt-4o", "png") +def test_model_database_xai_grok_aliases_and_responses_transport(): + assert ModelDatabase.get_default_provider("grok") == Provider.XAI + assert ModelDatabase.get_default_provider("grok-4.3") == Provider.XAI + assert ModelDatabase.get_default_provider("grok-4.3-latest") == Provider.XAI + assert ModelDatabase.get_default_provider("grok-4-latest") == Provider.XAI + assert ModelDatabase.get_default_provider("grok-3-latest") == Provider.XAI + + assert ModelDatabase.get_context_window("grok") == 1_000_000 + assert ModelDatabase.get_context_window("grok-4.3") == 1_000_000 + assert ModelDatabase.get_context_window("grok-4") == 1_000_000 + assert ModelDatabase.get_context_window("grok-4-0709") == 256000 + assert ModelDatabase.get_response_transports("grok-4.3") == ("sse", "websocket") + assert ModelDatabase.supports_response_websocket_provider("grok-4.3", Provider.XAI) + assert ModelDatabase.supports_response_websocket_provider( + "grok-4.3", + Provider.XAI_RESPONSES, + ) + + +def test_model_database_xai_image_input_mime_types_match_docs(): + vision_model = "grok-4-fast-reasoning" + + assert ModelDatabase.supports_mime(vision_model, "image/jpeg") + assert ModelDatabase.supports_mime(vision_model, "jpg") + assert ModelDatabase.supports_mime(vision_model, "image/png") + assert not ModelDatabase.supports_mime(vision_model, "image/webp") + assert not ModelDatabase.supports_mime("grok-4.3", "image/png") + + def test_model_database_google_video_audio_mime_types(): """Test that Google models support expanded video/audio MIME types.""" # Video formats (MP4, AVI, FLV, MOV, MPEG, MPG, WebM) @@ -356,8 +459,6 @@ def test_model_database_response_service_tiers() -> None: assert ModelDatabase.get_response_service_tiers("gpt-5.4") == ("fast", "flex") assert ModelDatabase.get_response_service_tiers("gpt-5.5") == ("fast", "flex") assert ModelDatabase.get_response_service_tiers("gpt-5.3-chat-latest") == ("fast",) - assert ModelDatabase.get_response_service_tiers("gpt-5.1-codex") == ("fast",) - assert ModelDatabase.get_response_service_tiers("gpt-5.2-codex") == ("fast", "flex") assert ModelDatabase.supports_response_service_tier("gpt-5.3-chat-latest", "flex") is False assert ModelDatabase.supports_response_service_tier("gpt-5.4", "flex") is True assert ModelDatabase.supports_response_service_tier("gpt-5.5", "flex") is True @@ -414,7 +515,12 @@ def test_glm_51_matches_glm_5_capabilities() -> None: assert old is not None assert new is not None - assert new.model_dump() == old.model_dump() + old_dump = old.model_dump() + new_dump = new.model_dump() + old_dump.pop("structured_tool_policy", None) + new_dump.pop("structured_tool_policy", None) + assert new_dump == old_dump + assert new.structured_tool_policy == "no_tools" def test_model_database_codex_spark_is_text_only() -> None: @@ -598,6 +704,18 @@ def test_huggingface_qwen35_default_reasoning_emits_chat_template_kwargs_enabled assert extra_body["chat_template_kwargs"] == {"enable_thinking": True} +def test_huggingface_deepseek_v4_pro_uses_reasoning_content_streaming_metadata(): + llm = _make_hf_llm("deepseek-ai/DeepSeek-V4-Pro:fireworks-ai") + + assert llm.default_request_params.model == "deepseek-ai/DeepSeek-V4-Pro" + assert llm.default_request_params.maxTokens == 393_216 + assert getattr(llm, "_reasoning_mode", None) == "reasoning_content" + + args = _hf_request_args(llm) + assert args["model"] == "deepseek-ai/DeepSeek-V4-Pro:fireworks-ai" + assert args["max_tokens"] == 393_216 + + def test_huggingface_qwen35_reasoning_stream_hidden_when_disabled(): llm = _make_hf_llm_with_reasoning("Qwen/Qwen3.5-397B-A17B", reasoning=False) @@ -650,3 +768,20 @@ def test_model_database_runtime_model_params_registration(): ModelDatabase.unregister_runtime_model_params(model_name) assert ModelDatabase.get_model_params(model_name) is None + + +def test_model_specific_defaults_for_gpt_53_plus_family(): + expected = "Before making tool calls, send a brief preamble to the user explaining what you’re about to do." + + for model_name in ( + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.3-chat-latest", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.5", + ): + assert ModelDatabase.get_model_specific(model_name) == expected + + assert ModelDatabase.get_model_specific("gpt-5.2") == "" diff --git a/tests/unit/fast_agent/llm/test_model_factory.py b/tests/unit/fast_agent/llm/test_model_factory.py index 8fc6c911e..559898505 100644 --- a/tests/unit/fast_agent/llm/test_model_factory.py +++ b/tests/unit/fast_agent/llm/test_model_factory.py @@ -34,7 +34,7 @@ TEST_ALIASES = { "kimi": "hf.moonshotai/Kimi-K2-Instruct-0905", # No default provider "glm": "hf.zai-org/GLM-4.6:cerebras", # Has default provider - "qwen3": "hf.Qwen/Qwen3-Next-80B-A3B-Instruct:together", + "qwen35": "hf.Qwen/Qwen3.5-397B-A17B:novita", "minimax": "hf.MiniMaxAI/MiniMax-M2", # No default provider } @@ -43,8 +43,8 @@ def test_simple_model_names(): """Test parsing of simple model names""" cases = [ ("o1-mini", Provider.RESPONSES), - ("claude-3-haiku-20240307", Provider.ANTHROPIC), - ("claude-3-5-sonnet-20240620", Provider.ANTHROPIC), + ("claude-haiku-4-5", Provider.ANTHROPIC), + ("claude-sonnet-4-6", Provider.ANTHROPIC), ("claude-opus-4-6", Provider.ANTHROPIC), ] @@ -59,9 +59,9 @@ def test_full_model_strings(): """Test parsing of full model strings with providers""" cases = [ ( - "anthropic.claude-3-haiku-20240307", + "anthropic.claude-haiku-4-5", Provider.ANTHROPIC, - "claude-3-haiku-20240307", + "claude-haiku-4-5", None, ), ("openai.gpt-4.1", Provider.OPENAI, "gpt-4.1", None), @@ -146,6 +146,13 @@ def test_model_query_structured_tool_use(): assert config.structured_output_mode == "tool_use" +def test_model_query_structured_tools_policy(): + config = ModelFactory.parse_model_string( + "claude-sonnet-4-6?structured=json&structured_tools=defer" + ) + assert config.structured_tool_policy == "defer" + + def test_model_query_unknown_parameter_is_rejected() -> None: with pytest.raises(ModelConfigError, match="Unsupported model query parameter"): ModelFactory.parse_model_string("claude-sonnet-4-6?routing=vertex") @@ -245,7 +252,7 @@ def test_kimi25_alias_sets_thinking_sampling_defaults() -> None: config = ModelFactory.parse_model_string("kimi25") assert config.provider == Provider.HUGGINGFACE - assert config.model_name == "moonshotai/Kimi-K2.5:fireworks-ai" + assert config.model_name == "moonshotai/Kimi-K2.5:novita" assert config.temperature == 1.0 assert config.top_p == 0.95 assert config.reasoning_effort == ReasoningEffortSetting(kind="toggle", value=True) @@ -255,7 +262,7 @@ def test_kimi25instant_alias_sets_instant_sampling_defaults() -> None: config = ModelFactory.parse_model_string("kimi25instant") assert config.provider == Provider.HUGGINGFACE - assert config.model_name == "moonshotai/Kimi-K2.5:fireworks-ai" + assert config.model_name == "moonshotai/Kimi-K2.5:novita" assert config.temperature == 0.6 assert config.top_p == 0.95 assert config.reasoning_effort == ReasoningEffortSetting(kind="toggle", value=False) @@ -265,6 +272,17 @@ def test_kimi_alias_matches_current_promoted_kimi_defaults() -> None: assert ModelFactory.parse_model_string("kimi") == ModelFactory.parse_model_string("kimi26") +def test_kimithink_alias_maps_to_current_kimi_defaults() -> None: + assert ModelFactory.parse_model_string("kimithink") == ModelFactory.parse_model_string("kimi26") + + +def test_direct_kimi_model_routes_to_huggingface() -> None: + config = ModelFactory.parse_model_string("moonshotai/kimi-k2") + + assert config.provider == Provider.HUGGINGFACE + assert config.model_name == "moonshotai/kimi-k2" + + def test_kimi26_alias_sets_thinking_sampling_defaults() -> None: config = ModelFactory.parse_model_string("kimi26") @@ -303,7 +321,7 @@ def test_model_query_transport_websocket_alias(): def test_model_query_transport_auto(): - config = ModelFactory.parse_model_string("codexplan52?transport=auto") + config = ModelFactory.parse_model_string("codexplan?transport=auto") assert config.transport == "auto" @@ -347,14 +365,6 @@ def test_chatgpt_alias_flex_service_tier_query_rejected() -> None: ModelFactory.parse_model_string("chatgpt?service_tier=flex") -def test_responses_codex_52_flex_service_tier_query_allowed() -> None: - config = ModelFactory.parse_model_string("responses.gpt-5.2-codex?service_tier=flex") - - assert config.provider == Provider.RESPONSES - assert config.model_name == "gpt-5.2-codex" - assert config.service_tier == "flex" - - def test_responses_codex_53_flex_service_tier_query_rejected() -> None: with pytest.raises(ModelConfigError, match="gpt-5.3-codex"): ModelFactory.parse_model_string("responses.gpt-5.3-codex?service_tier=flex") @@ -429,6 +439,20 @@ def test_transport_query_allows_codexresponses_provider_for_codex_spark(): assert config.transport == "websocket" +def test_transport_query_allows_xairesponses_provider_for_grok(): + config = ModelFactory.parse_model_string("xairesponses.grok-4.3?transport=ws") + assert config.provider == Provider.XAI_RESPONSES + assert config.model_name == "grok-4.3" + assert config.transport == "websocket" + + +def test_transport_query_allows_xai_provider_for_grok(): + config = ModelFactory.parse_model_string("xai.grok-4.3?transport=ws") + assert config.provider == Provider.XAI + assert config.model_name == "grok-4.3" + assert config.transport == "websocket" + + def test_transport_query_rejects_openai_provider_even_with_responses_model(): with pytest.raises(ModelConfigError): ModelFactory.parse_model_string("openai.gpt-5?transport=ws") @@ -456,6 +480,29 @@ def test_factory_passes_transport_to_responses_llm_for_openai_responses_model() assert llm._transport == "websocket" +def test_factory_builds_xairesponses_llm() -> None: + factory = ModelFactory.create_factory("xairesponses.grok-4.3?transport=ws") + llm = factory(LlmAgent(AgentConfig(name="Test Agent"))) + assert isinstance(llm, ResponsesLLM) + assert llm.provider == Provider.XAI_RESPONSES + assert llm._transport == "websocket" + + +def test_factory_builds_xai_responses_llm_by_default() -> None: + factory = ModelFactory.create_factory("xai.grok-4.3?transport=ws") + llm = factory(LlmAgent(AgentConfig(name="Test Agent"))) + assert isinstance(llm, ResponsesLLM) + assert llm.provider == Provider.XAI + assert llm._transport == "websocket" + + +def test_factory_builds_xai_legacy_llm() -> None: + factory = ModelFactory.create_factory("xai_legacy.grok-4") + llm = factory(LlmAgent(AgentConfig(name="Test Agent"))) + assert isinstance(llm, OpenAILLM) + assert llm.provider == Provider.XAI_LEGACY + + def test_factory_passes_service_tier_query_to_request_params() -> None: factory = ModelFactory.create_factory("responses.gpt-5?service_tier=fast") llm = factory(LlmAgent(AgentConfig(name="Test Agent"))) @@ -552,7 +599,7 @@ def test_llm_class_creation(): """Test creation of LLM classes""" cases = [ ("gpt-4.1", OpenAILLM), - ("claude-3-haiku-20240307", AnthropicLLM), + ("claude-haiku-4-5", AnthropicLLM), ("openai.gpt-4.1", OpenAILLM), ] @@ -608,6 +655,14 @@ def test_claude_alias_resolves_to_sonnet_46(): assert config.provider == Provider.ANTHROPIC assert config.model_name == "claude-sonnet-4-6" + config = ModelFactory.parse_model_string("sonnet4") + assert config.provider == Provider.ANTHROPIC + assert config.model_name == "claude-sonnet-4-6" + + config = ModelFactory.parse_model_string("opus4") + assert config.provider == Provider.ANTHROPIC + assert config.model_name == "claude-opus-4-7" + config = ModelFactory.parse_model_string("opus46") assert config.provider == Provider.ANTHROPIC assert config.model_name == "claude-opus-4-6" @@ -623,6 +678,48 @@ def test_gemini31_alias_resolves_to_google_31_preview(): assert config.model_name == "gemini-3.1-pro-preview" +def test_gemini31_flash_lite_alias_resolves_to_google_preview(): + config = ModelFactory.parse_model_string("gemini3.1flashlite") + assert config.provider == Provider.GOOGLE + assert config.model_name == "gemini-3.1-flash-lite-preview" + + +def test_gemini25_alias_resolves_to_current_google_flash(): + config = ModelFactory.parse_model_string("gemini25") + assert config.provider == Provider.GOOGLE + assert config.model_name == "gemini-2.5-flash" + + +def test_grok_aliases_resolve_to_xai_grok_43(): + config = ModelFactory.parse_model_string("grok") + assert config.provider == Provider.XAI + assert config.model_name == "grok-4.3" + + config = ModelFactory.parse_model_string("grok4") + assert config.provider == Provider.XAI + assert config.model_name == "grok-4.3" + + +def test_deepseek_alias_resolves_to_hf_deepseek_v4_pro(): + config = ModelFactory.parse_model_string("deepseek") + assert config.provider == Provider.HUGGINGFACE + assert config.model_name == "deepseek-ai/DeepSeek-V4-Pro:fireworks-ai" + + +def test_deepseek4_alias_resolves_to_hf_deepseek_v4_pro(): + config = ModelFactory.parse_model_string("deepseek4") + assert config.provider == Provider.HUGGINGFACE + assert config.model_name == "deepseek-ai/DeepSeek-V4-Pro:fireworks-ai" + + +def test_hf_routed_gpt_oss_alias_resolves_model_metadata(): + resolved = ModelFactory.resolve_model_spec("gpt-oss") + + assert resolved.provider == Provider.HUGGINGFACE + assert resolved.wire_model_name == "openai/gpt-oss-120b:cerebras" + assert resolved.max_output_tokens == 32766 + + def test_curated_catalog_aliases_are_parseable(): for entry in ModelSelectionCatalog.list_current_entries(): if "?" in entry.model: @@ -648,10 +745,6 @@ def test_codexplan_aliases_use_codex_oauth_provider(): assert config.provider == Provider.RESPONSES assert config.model_name == "gpt-5.4" - config = ModelFactory.parse_model_string("codexplan52") - assert config.provider == Provider.CODEX_RESPONSES - assert config.model_name == "gpt-5.2-codex" - config = ModelFactory.parse_model_string("codexspark") assert config.provider == Provider.CODEX_RESPONSES assert config.model_name == "gpt-5.3-codex-spark" @@ -663,7 +756,7 @@ def test_codexplan_aliases_use_codex_oauth_provider(): ("glm", "zai-org/GLM-4.6:cerebras"), ("glm:groq", "zai-org/GLM-4.6:groq"), ("kimi:groq", "moonshotai/Kimi-K2-Instruct-0905:groq"), - ("qwen3:nebius", "Qwen/Qwen3-Next-80B-A3B-Instruct:nebius"), + ("qwen35:nebius", "Qwen/Qwen3.5-397B-A17B:nebius"), ], ) def test_huggingface_alias_provider_routing_contracts( diff --git a/tests/unit/fast_agent/llm/test_model_info_caps.py b/tests/unit/fast_agent/llm/test_model_info_caps.py index 2e83c3068..668b33331 100644 --- a/tests/unit/fast_agent/llm/test_model_info_caps.py +++ b/tests/unit/fast_agent/llm/test_model_info_caps.py @@ -61,7 +61,7 @@ def model_info(self) -> "ModelInfo | None": def test_model_alias_capabilities_match_canonical() -> None: alias = ModelInfo.from_name("gemini25") - canonical = ModelInfo.from_name("gemini-2.5-flash-preview-09-2025") + canonical = ModelInfo.from_name("gemini-2.5-flash") assert alias is not None assert canonical is not None @@ -73,7 +73,7 @@ def test_model_alias_capabilities_match_canonical() -> None: def test_model_info_from_llm_uses_canonical_name() -> None: info = ModelInfo.from_llm(cast("FastAgentLLMProtocol", DummyLLM("gemini25"))) assert info is not None - assert info.name == "gemini-2.5-flash-preview-09-2025" + assert info.name == "gemini-2.5-flash" assert info.tdv_flags == (True, True, True) diff --git a/tests/unit/fast_agent/llm/test_model_overlays.py b/tests/unit/fast_agent/llm/test_model_overlays.py index 546e20081..e398ae1cf 100644 --- a/tests/unit/fast_agent/llm/test_model_overlays.py +++ b/tests/unit/fast_agent/llm/test_model_overlays.py @@ -25,7 +25,10 @@ from fast_agent.agents.llm_agent import LlmAgent from fast_agent.llm.model_database import ModelDatabase from fast_agent.llm.model_factory import ModelFactory -from fast_agent.llm.model_overlays import load_model_overlay_registry +from fast_agent.llm.model_overlays import ( + build_model_overlay_manifest_from_database, + load_model_overlay_registry, +) from fast_agent.llm.model_selection import ModelSelectionCatalog from fast_agent.llm.provider.openai.openresponses import OpenResponsesLLM from fast_agent.llm.provider_types import Provider @@ -48,6 +51,34 @@ def _overlay_group(snapshot): return next(option for option in snapshot.providers if option.overlay_group) +def test_export_preserves_explicit_provider_for_namespaced_model() -> None: + manifest = build_model_overlay_manifest_from_database("openrouter.moonshotai/kimi-k2") + + assert manifest.provider == Provider.OPENROUTER + assert manifest.model == "moonshotai/kimi-k2" + + +def test_export_preserves_explicit_provider_over_catalog_default() -> None: + manifest = build_model_overlay_manifest_from_database("openrouter.gpt-4o") + + assert manifest.provider == Provider.OPENROUTER + assert manifest.model == "gpt-4o" + + +def test_export_preserves_hf_namespace_before_database_lookup() -> None: + manifest = build_model_overlay_manifest_from_database("hf.openai/gpt-oss-120b:cerebras") + + assert manifest.provider == Provider.HUGGINGFACE + assert manifest.model == "openai/gpt-oss-120b:cerebras" + + +def test_export_preserves_bare_hf_namespace_that_matches_provider() -> None: + manifest = build_model_overlay_manifest_from_database("openai/gpt-oss-120b") + + assert manifest.provider == Provider.HUGGINGFACE + assert manifest.model == "openai/gpt-oss-120b" + + def test_same_provider_overlays_create_distinct_openresponses_clients(tmp_path: Path) -> None: env_dir = tmp_path / ".fast-agent" _write_overlay( @@ -137,6 +168,9 @@ def test_overlay_presets_resolve_overlay_metadata_and_picker_entries(tmp_path: P metadata: context_window: 65536 max_output_tokens: 2048 + json_mode: none + structured_tool_policy: defer + model_specific: Overlay-specific instructions. fast: true """.strip(), ) @@ -164,6 +198,9 @@ def test_overlay_presets_resolve_overlay_metadata_and_picker_entries(tmp_path: P assert resolved.wire_model_name == "overlay-tests/Qwen-Picker" assert params.context_window == 65536 assert params.max_output_tokens == 2048 + assert params.json_mode is None + assert params.structured_tool_policy == "defer" + assert params.model_specific == "Overlay-specific instructions." assert params.fast is True assert ModelDatabase.get_model_params("overlay-tests/Qwen-Picker") is None @@ -301,6 +338,40 @@ def test_overlay_resolution_precedence_beats_custom_preset(tmp_path: Path) -> No os.environ["ENVIRONMENT_DIR"] = previous_env_dir +def test_new_overlay_model_defaults_to_schema_json_mode(tmp_path: Path) -> None: + env_dir = tmp_path / ".fast-agent" + _write_overlay( + env_dir, + "schema-default.yaml", + """ +name: schema-default +provider: openresponses +model: overlay-tests/Schema-Default +connection: + base_url: http://localhost:8081/v1 + auth: none +metadata: + context_window: 65536 + max_output_tokens: 2048 +""".strip(), + ) + + previous_env_dir = os.environ.get("ENVIRONMENT_DIR") + os.environ["ENVIRONMENT_DIR"] = str(env_dir) + + try: + resolved = ModelFactory.resolve_model_spec("schema-default") + + assert resolved.model_params is not None + assert resolved.model_params.json_mode == "schema" + finally: + _cleanup_overlay_runtime_state(tmp_path) + if previous_env_dir is None: + os.environ.pop("ENVIRONMENT_DIR", None) + else: + os.environ["ENVIRONMENT_DIR"] = previous_env_dir + + def test_overlay_known_model_metadata_applies_to_llm_model_info(tmp_path: Path) -> None: env_dir = tmp_path / ".fast-agent" _write_overlay( diff --git a/tests/unit/fast_agent/llm/test_model_reference_config.py b/tests/unit/fast_agent/llm/test_model_reference_config.py index 608ad9832..e0bad1044 100644 --- a/tests/unit/fast_agent/llm/test_model_reference_config.py +++ b/tests/unit/fast_agent/llm/test_model_reference_config.py @@ -33,7 +33,7 @@ def test_set_reference_dry_run_does_not_mutate_target_file(tmp_path) -> None: dry_run=True, ) - assert result.target_path == env_dir / "fastagent.config.yaml" + assert result.target_path == env_dir / "fast-agent.yaml" assert result.applied is False assert result.dry_run is True assert result.changes[0].old is None @@ -51,7 +51,7 @@ def test_set_reference_writes_env_target_and_creates_config_file(tmp_path) -> No result = service.set_reference("$system.fast", "claude-haiku-4-5", target="env") assert result.applied is True - assert result.target_path == env_dir / "fastagent.config.yaml" + assert result.target_path == env_dir / "fast-agent.yaml" saved = _read_yaml(result.target_path) assert saved["model_references"]["system"]["fast"] == "claude-haiku-4-5" @@ -60,7 +60,7 @@ def test_set_reference_preserves_existing_yaml_comments(tmp_path) -> None: workspace = tmp_path / "workspace" env_dir = workspace / ".fast-agent" workspace.mkdir(parents=True) - config_path = env_dir / "fastagent.config.yaml" + config_path = env_dir / "fast-agent.yaml" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text( ( @@ -89,7 +89,7 @@ def test_set_reference_preserves_existing_yaml_comments(tmp_path) -> None: def test_unset_reference_writes_project_target(tmp_path) -> None: workspace = tmp_path / "workspace" workspace.mkdir(parents=True) - project_config = workspace / "fastagent.config.yaml" + project_config = workspace / "fast-agent.yaml" _write_yaml( project_config, { @@ -118,7 +118,7 @@ def test_list_references_uses_project_env_and_secrets_layering(tmp_path) -> None workspace.mkdir(parents=True) _write_yaml( - workspace / "fastagent.config.yaml", + workspace / "fast-agent.yaml", { "model_references": { "system": { @@ -129,7 +129,7 @@ def test_list_references_uses_project_env_and_secrets_layering(tmp_path) -> None }, ) _write_yaml( - env_dir / "fastagent.config.yaml", + env_dir / "fast-agent.yaml", { "model_references": { "system": { diff --git a/tests/unit/fast_agent/llm/test_model_selection_catalog.py b/tests/unit/fast_agent/llm/test_model_selection_catalog.py index d992ed497..6eea37e9f 100644 --- a/tests/unit/fast_agent/llm/test_model_selection_catalog.py +++ b/tests/unit/fast_agent/llm/test_model_selection_catalog.py @@ -56,11 +56,12 @@ def test_list_curated_models_for_provider() -> None: assert "claude-haiku-4-5" in models assert "claude-sonnet-4-6" in models assert "claude-opus-4-7" in models + assert "claude-opus-4-6" in models def test_list_curated_aliases_for_provider() -> None: aliases = ModelSelectionCatalog.list_curated_aliases(Provider.ANTHROPIC) - assert aliases == ["sonnet", "haiku", "opus"] + assert aliases == ["opus", "opus46", "sonnet", "haiku"] def test_legacy_aliases_are_listed_but_not_curated() -> None: @@ -77,10 +78,18 @@ def test_legacy_aliases_are_listed_but_not_curated() -> None: assert "glm5" in legacy_aliases assert "glm47" in legacy_aliases assert "glm47" not in curated_aliases + assert "deepseek4" not in curated_aliases + assert "deepseek4" in legacy_aliases + assert "deepseek32" in legacy_aliases def test_list_fast_models_uses_explicit_curated_designation() -> None: - for provider in (Provider.ANTHROPIC, Provider.CODEX_RESPONSES, Provider.HUGGINGFACE, Provider.GROQ): + for provider in ( + Provider.ANTHROPIC, + Provider.CODEX_RESPONSES, + Provider.HUGGINGFACE, + Provider.GROQ, + ): assert ModelSelectionCatalog.list_fast_models(provider) == [ entry.model for entry in _static_current_entries(provider) if entry.fast ] diff --git a/tests/unit/fast_agent/llm/test_openrouter_model_lookup.py b/tests/unit/fast_agent/llm/test_openrouter_model_lookup.py index eaa85660b..910a82094 100644 --- a/tests/unit/fast_agent/llm/test_openrouter_model_lookup.py +++ b/tests/unit/fast_agent/llm/test_openrouter_model_lookup.py @@ -96,5 +96,6 @@ def test_openrouter_runtime_registration_does_not_override_static_models(monkeyp # Static metadata should remain unchanged for known models. assert ModelDatabase.get_max_output_tokens("moonshotai/kimi-k2") == 16384 + assert ModelDatabase.get_default_provider("moonshotai/kimi-k2") == Provider.HUGGINGFACE ModelDatabase.clear_runtime_model_params(Provider.OPENROUTER) diff --git a/tests/unit/fast_agent/llm/test_structured.py b/tests/unit/fast_agent/llm/test_structured.py index d84b4b437..d43222690 100644 --- a/tests/unit/fast_agent/llm/test_structured.py +++ b/tests/unit/fast_agent/llm/test_structured.py @@ -2,9 +2,11 @@ import pytest from mcp import Tool +from mcp.types import CallToolResult, TextContent from pydantic import BaseModel from fast_agent.llm.internal.passthrough import PassthroughLLM +from fast_agent.llm.provider.anthropic.llm_anthropic import AnthropicLLM from fast_agent.llm.provider.openai.llm_openai import OpenAILLM from fast_agent.llm.provider.openai.llm_openai_compatible import OpenAICompatibleLLM from fast_agent.mcp.prompt import Prompt @@ -27,8 +29,8 @@ class StructuredValue(BaseModel): class _CompatibleStructuredHarness(OpenAICompatibleLLM): - def __init__(self) -> None: - self.default_request_params = RequestParams(model="test-compatible-model") + def __init__(self, model: str = "qwen/qwen3-32b") -> None: + self.default_request_params = RequestParams(model=model) async def _apply_prompt_provider_specific( self, @@ -49,6 +51,7 @@ def __init__(self) -> None: super().__init__(name="generate-prepare") self.prepare_called = False self.applied_params: RequestParams | None = None + self.applied_tools = None def _prepare_structured_request( self, @@ -69,11 +72,35 @@ async def _apply_prompt_provider_specific( tools=None, is_template: bool = False, ): - del multipart_messages, tools, is_template + del multipart_messages, is_template self.applied_params = request_params + self.applied_tools = tools return Prompt.assistant('{"value":"ok"}') +class _StructuredModelPathHarness(PassthroughLLM): + def __init__(self) -> None: + super().__init__(name="structured-model-path") + self.model_path_called = False + + async def _apply_prompt_provider_specific_structured( + self, + multipart_messages, + model, + request_params=None, + ): + del multipart_messages, request_params + self.model_path_called = True + response = Prompt.assistant('{"value":"ok"}') + return model(value="ok"), response + + +class _DefaultDeferOpenAIHarness(OpenAILLM): + def _default_structured_tool_policy(self, model_name: str | None): + del model_name + return "defer" + + @pytest.mark.asyncio async def test_direct_pydantic_conversion(): # JSON string that would typically come from an LLM @@ -108,6 +135,18 @@ async def test_direct_pydantic_conversion(): assert result.categories[1].reasoning is None +@pytest.mark.asyncio +async def test_structured_uses_provider_model_path(): + llm = _StructuredModelPathHarness() + + result, response = await llm.structured([Prompt.user("return json")], StructuredValue) + + assert llm.model_path_called + assert result is not None + assert result.value == "ok" + assert response.last_text() == '{"value":"ok"}' + + @pytest.mark.asyncio async def test_structured_with_bad_json(): # JSON string that would typically come from an LLM @@ -204,6 +243,89 @@ async def test_structured_schema_delegates_through_generate_prepare_hook(): assert llm.applied_params.response_format == {"type": "json_object"} +@pytest.mark.asyncio +async def test_generate_strips_tools_for_deferred_structured_final_turn(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + tool = Tool( + name="lookup", + description="Lookup data.", + inputSchema={"type": "object", "properties": {}}, + ) + llm = _GeneratePrepareHarness() + messages = [ + Prompt.user("call the tool"), + PromptMessageExtended( + role="user", + content=[], + tool_results={ + "call_1": CallToolResult( + content=[TextContent(type="text", text="tool payload")], + ) + }, + ), + ] + + await llm.generate( + messages, + RequestParams(structured_schema=schema, structured_tool_policy="defer"), + [tool], + ) + + assert llm.applied_params is not None + assert llm.applied_params.response_format == {"type": "json_object"} + assert llm.applied_tools is None + + +@pytest.mark.asyncio +async def test_generate_keeps_tools_for_deferred_first_turn(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + tool = Tool( + name="lookup", + description="Lookup data.", + inputSchema={"type": "object", "properties": {}}, + ) + llm = _GeneratePrepareHarness() + + await llm.generate( + [Prompt.user("call the tool")], + RequestParams(structured_schema=schema, structured_tool_policy="defer"), + [tool], + ) + + assert llm.applied_tools == [tool] + + +@pytest.mark.asyncio +async def test_generate_strips_tools_for_no_tools_structured_policy(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + tool = Tool( + name="lookup", + description="Lookup data.", + inputSchema={"type": "object", "properties": {}}, + ) + llm = _GeneratePrepareHarness() + + await llm.generate( + [Prompt.user("return json")], + RequestParams(structured_schema=schema, structured_tool_policy="no_tools"), + [tool], + ) + + assert llm.applied_tools is None + + def test_openai_prepare_structured_request_sets_native_response_format(): schema = { "type": "object", @@ -212,7 +334,7 @@ def test_openai_prepare_structured_request_sets_native_response_format(): } llm = OpenAILLM(model="gpt-4.1") messages = [Prompt.user("return json")] - params = RequestParams(structured_schema=schema) + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") prepared_messages, prepared_params = llm._prepare_structured_request(messages, params) @@ -221,6 +343,77 @@ def test_openai_prepare_structured_request_sets_native_response_format(): assert prepared_params.response_format == llm.schema_to_response_format(schema) +def test_openai_prepare_structured_request_defers_with_tools_until_tool_result(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + tool = Tool( + name="lookup", + description="Lookup data.", + inputSchema={"type": "object", "properties": {}}, + ) + llm = OpenAILLM(model="gpt-4.1") + messages = [Prompt.user("call the tool")] + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") + + prepared_messages, prepared_params = llm._prepare_structured_request( + messages, + params, + [tool], + ) + + assert prepared_messages is messages + assert prepared_params.structured_schema is None + assert prepared_params.response_format is None + assert params.structured_schema == schema + + +def test_structured_tool_policy_auto_uses_provider_default_and_allows_override(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + tool = Tool( + name="lookup", + description="Lookup data.", + inputSchema={"type": "object", "properties": {}}, + ) + llm = _DefaultDeferOpenAIHarness(model="gpt-4.1") + messages = [Prompt.user("call the tool")] + + _, auto_params = llm._prepare_structured_request( + messages, + RequestParams(structured_schema=schema), + [tool], + ) + _, override_params = llm._prepare_structured_request( + messages, + RequestParams(structured_schema=schema, structured_tool_policy="always"), + [tool], + ) + + assert auto_params.structured_schema is None + assert override_params.response_format == llm.schema_to_response_format(schema) + + +def test_anthropic_tool_use_structured_mode_defaults_to_no_tools_policy(): + llm = AnthropicLLM(model="claude-sonnet-4-6", structured_output_mode="tool_use") + + assert ( + llm.resolve_structured_tool_policy(RequestParams(structured_schema={"type": "object"})) + == "no_tools" + ) + assert ( + llm.resolve_structured_tool_policy( + RequestParams(structured_schema={"type": "object"}, structured_tool_policy="defer") + ) + == "defer" + ) + + def test_openai_compatible_prepare_structured_request_prompts_without_mutating_history(): schema = { "type": "object", @@ -229,7 +422,7 @@ def test_openai_compatible_prepare_structured_request_prompts_without_mutating_h } llm = _CompatibleStructuredHarness() original = Prompt.user("return json") - params = RequestParams(structured_schema=schema) + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") prepared_messages, prepared_params = llm._prepare_structured_request([original], params) @@ -240,6 +433,78 @@ def test_openai_compatible_prepare_structured_request_prompts_without_mutating_h assert prepared_params.response_format == {"type": "json_object"} +def test_openai_compatible_prepare_structured_request_uses_schema_mode_without_prompt(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + llm = _CompatibleStructuredHarness(model="moonshotai/Kimi-K2.6") + original = Prompt.user("return json") + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") + + prepared_messages, prepared_params = llm._prepare_structured_request([original], params) + + assert prepared_messages[0].all_text() == "return json" + assert prepared_params.response_format == llm.schema_to_response_format(schema) + + +def test_openai_compatible_prepare_structured_request_uses_request_model_override(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + llm = _CompatibleStructuredHarness(model="qwen/qwen3-32b") + original = Prompt.user("return json") + params = RequestParams( + model="moonshotai/Kimi-K2.6", + structured_schema=schema, + structured_tool_policy="defer", + ) + + prepared_messages, prepared_params = llm._prepare_structured_request([original], params) + + assert prepared_messages[0].all_text() == "return json" + assert prepared_params.response_format == llm.schema_to_response_format(schema) + + +def test_openai_compatible_prepare_structured_request_request_model_override_to_object_mode(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + llm = _CompatibleStructuredHarness(model="moonshotai/Kimi-K2.6") + original = Prompt.user("return json") + params = RequestParams( + model="qwen/qwen3-32b", + structured_schema=schema, + structured_tool_policy="defer", + ) + + prepared_messages, prepared_params = llm._prepare_structured_request([original], params) + + assert "YOU MUST RESPOND WITH A JSON OBJECT" in prepared_messages[0].all_text() + assert prepared_params.response_format == {"type": "json_object"} + + +def test_openai_compatible_prepare_structured_request_prompt_only_for_no_json_mode(): + schema = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + } + llm = _CompatibleStructuredHarness(model="claude-sonnet-4-0") + original = Prompt.user("return json") + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") + + prepared_messages, prepared_params = llm._prepare_structured_request([original], params) + + assert "YOU MUST RESPOND WITH A JSON OBJECT" in prepared_messages[0].all_text() + assert prepared_params.response_format is None + + def test_openai_compatible_prepare_structured_request_defers_until_tool_result(): schema = { "type": "object", @@ -253,7 +518,7 @@ def test_openai_compatible_prepare_structured_request_defers_until_tool_result() ) llm = _CompatibleStructuredHarness() original = Prompt.user("call the tool") - params = RequestParams(structured_schema=schema) + params = RequestParams(structured_schema=schema, structured_tool_policy="defer") prepared_messages, prepared_params = llm._prepare_structured_request( [original], @@ -262,8 +527,9 @@ def test_openai_compatible_prepare_structured_request_defers_until_tool_result() ) assert prepared_messages[0].all_text() == "call the tool" - assert prepared_params is params assert prepared_params.response_format is None + assert prepared_params.structured_schema is None + assert params.structured_schema == schema @pytest.mark.asyncio diff --git a/tests/unit/fast_agent/llm/test_usage_tracking.py b/tests/unit/fast_agent/llm/test_usage_tracking.py index b5a97e688..4ee75951c 100644 --- a/tests/unit/fast_agent/llm/test_usage_tracking.py +++ b/tests/unit/fast_agent/llm/test_usage_tracking.py @@ -312,13 +312,13 @@ def test_cache_hit_rate_calculation(): openai_turn = TurnUsage.from_openai(openai_usage, "gpt-4o") accumulator.add_turn(openai_turn) - # With our updated algorithm: + # Cache hit rate is the share of input context served from cache. # Anthropic cumulative_input: 1000 + 300 (cache read) = 1300 # OpenAI cumulative_input: 800 (already includes cache) # Total cumulative_input: 1300 + 800 = 2100 # Total cache: 300 (anthropic read) + 200 (openai hit) = 500 - # Hit rate: 500 / (2100 + 500) * 100 = 500/2600 = 19.23% - expected_hit_rate = 500 / (2100 + 500) * 100 + # Hit rate: 500 / 2100 * 100 = 23.81% + expected_hit_rate = 500 / 2100 * 100 assert accumulator.cache_hit_rate is not None assert abs(accumulator.cache_hit_rate - expected_hit_rate) < 0.01 diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py index ea95207d0..1c092438e 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_nonpersistent.py @@ -113,7 +113,8 @@ async def test_initialize_server_creates_and_tears_down_session(monkeypatch) -> transport_exited = False @asynccontextmanager - async def _fake_transport(server_name, config, trigger_oauth=None): + async def _fake_transport(server_name, config, trigger_oauth=None, **kwargs): + del kwargs del trigger_oauth nonlocal transport_entered, transport_exited transport_entered = True @@ -179,7 +180,8 @@ async def test_initialize_server_forwards_server_config_to_custom_factory(monkey captured_server_config = None @asynccontextmanager - async def _fake_transport(server_name, config, trigger_oauth=None): + async def _fake_transport(server_name, config, trigger_oauth=None, **kwargs): + del kwargs del trigger_oauth yield (object(), object(), None) @@ -212,7 +214,8 @@ async def test_initialize_server_retries_with_oauth_after_401(monkeypatch) -> No trigger_history: list[bool | None] = [] @asynccontextmanager - async def _fake_transport(server_name, config, trigger_oauth=None): + async def _fake_transport(server_name, config, trigger_oauth=None, **kwargs): + del kwargs del server_name, config trigger_history.append(trigger_oauth) yield (object(), object(), None) diff --git a/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py b/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py index 121c4ee77..1409055d0 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py +++ b/tests/unit/fast_agent/mcp/test_mcp_aggregator_skybridge.py @@ -41,6 +41,7 @@ class StrEnum(str, enum.Enum): MCPAggregator = _module.MCPAggregator SkybridgeServerConfig = _module.SkybridgeServerConfig SKYBRIDGE_MIME_TYPE = _module.SKYBRIDGE_MIME_TYPE +MCP_APP_MIME_TYPE = _module.MCP_APP_MIME_TYPE NamespacedTool = _module.NamespacedTool @@ -111,6 +112,99 @@ def test_skybridge_detection_marks_valid_resources() -> None: ) +def test_mcp_app_detection_marks_valid_resources() -> None: + aggregator = _create_aggregator() + + aggregator.server_supports_feature = AsyncMock(return_value=True) + aggregator._server_to_tool_map["test"] = [ + NamespacedTool( + tool=_tool_with_meta( + name="tool_a", + input_schema={"type": "object"}, + meta={"ui": {"resourceUri": "ui://component/app"}}, + ), + server_name="test", + namespaced_tool_name="test.tool_a", + ) + ] + aggregator._list_resources_from_server = AsyncMock( + return_value=[SimpleNamespace(uri="ui://component/app")] + ) + aggregator._get_resource_from_server = AsyncMock( + return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=MCP_APP_MIME_TYPE)]) + ) + + _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) + + assert config.enabled is True + assert config.has_mcp_apps is True + assert len(config.ui_resources) == 1 + assert config.ui_resources[0].is_mcp_app is True + assert len(config.tools) == 1 + tool_cfg = config.tools[0] + assert tool_cfg.is_valid is True + assert tool_cfg.kind is _module.AppIntegrationKind.MCP_APP + assert tool_cfg.visibility == ["model", "app"] + + +def test_mcp_app_detection_supports_legacy_flat_resource_uri() -> None: + aggregator = _create_aggregator() + + aggregator.server_supports_feature = AsyncMock(return_value=True) + aggregator._server_to_tool_map["test"] = [ + NamespacedTool( + tool=_tool_with_meta( + name="tool_a", + input_schema={"type": "object"}, + meta={"ui/resourceUri": "ui://component/app"}, + ), + server_name="test", + namespaced_tool_name="test.tool_a", + ) + ] + aggregator._list_resources_from_server = AsyncMock( + return_value=[SimpleNamespace(uri="ui://component/app")] + ) + aggregator._get_resource_from_server = AsyncMock( + return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=MCP_APP_MIME_TYPE)]) + ) + + _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) + + assert config.tools[0].is_valid is True + assert config.tools[0].kind is _module.AppIntegrationKind.MCP_APP + + +def test_mcp_app_detection_warns_on_skybridge_mime() -> None: + aggregator = _create_aggregator() + + aggregator.server_supports_feature = AsyncMock(return_value=True) + aggregator._server_to_tool_map["test"] = [ + NamespacedTool( + tool=_tool_with_meta( + name="tool_a", + input_schema={"type": "object"}, + meta={"ui": {"resourceUri": "ui://component/app"}}, + ), + server_name="test", + namespaced_tool_name="test.tool_a", + ) + ] + aggregator._list_resources_from_server = AsyncMock( + return_value=[SimpleNamespace(uri="ui://component/app")] + ) + aggregator._get_resource_from_server = AsyncMock( + return_value=SimpleNamespace(contents=[SimpleNamespace(mimeType=SKYBRIDGE_MIME_TYPE)]) + ) + + _, config = asyncio.run(aggregator._evaluate_skybridge_for_server("test")) + + assert config.enabled is True + assert config.ui_resources[0].is_skybridge is True + assert config.tools[0].is_valid is False + assert "instead of 'text/html;profile=mcp-app'" in (config.tools[0].warning or "") + + def test_skybridge_detection_warns_on_invalid_mime() -> None: aggregator = _create_aggregator() aggregator.server_supports_feature = AsyncMock(return_value=True) @@ -233,6 +327,70 @@ def test_list_tools_marks_skybridge_meta() -> None: assert meta.get("openai/skybridgeTemplate") == "ui://component/app" +def test_list_tools_marks_mcp_app_meta_and_hides_app_only_tools() -> None: + aggregator = _create_aggregator() + aggregator.initialized = True + + model_tool = _tool_with_meta( + name="model_tool", + input_schema={"type": "object"}, + meta={"ui": {"resourceUri": "ui://component/model", "visibility": ["model"]}}, + ) + app_tool = _tool_with_meta( + name="app_tool", + input_schema={"type": "object"}, + meta={"ui": {"resourceUri": "ui://component/app", "visibility": ["app"]}}, + ) + + model_namespaced = NamespacedTool( + tool=model_tool, + server_name="test", + namespaced_tool_name="test.model_tool", + ) + app_namespaced = NamespacedTool( + tool=app_tool, + server_name="test", + namespaced_tool_name="test.app_tool", + ) + + aggregator._namespaced_tool_map = { + "test.model_tool": model_namespaced, + "test.app_tool": app_namespaced, + } + aggregator._server_to_tool_map["test"] = [model_namespaced, app_namespaced] + aggregator._skybridge_configs["test"] = SkybridgeServerConfig( + server_name="test", + supports_resources=True, + tools=[ + _module.SkybridgeToolConfig( + tool_name="model_tool", + namespaced_tool_name="test.model_tool", + template_uri=_module.AnyUrl("ui://component/model"), + resource_uri=_module.AnyUrl("ui://component/model"), + kind=_module.AppIntegrationKind.MCP_APP, + visibility=["model"], + is_valid=True, + ), + _module.SkybridgeToolConfig( + tool_name="app_tool", + namespaced_tool_name="test.app_tool", + template_uri=_module.AnyUrl("ui://component/app"), + resource_uri=_module.AnyUrl("ui://component/app"), + kind=_module.AppIntegrationKind.MCP_APP, + visibility=["app"], + is_valid=True, + ), + ], + ) + + tools_result = asyncio.run(aggregator.list_tools()) + + assert [tool.name for tool in tools_result.tools] == ["test.model_tool"] + meta = tools_result.tools[0].meta or {} + assert meta.get("ui/appEnabled") is True + assert meta.get("ui/appTemplate") == "ui://component/model" + + def test_skybridge_resource_without_tool_warns() -> None: aggregator = _create_aggregator() diff --git a/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py b/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py index 1380f452d..a5d56a62e 100644 --- a/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py +++ b/tests/unit/fast_agent/mcp/test_mcp_connection_manager.py @@ -332,6 +332,56 @@ async def _fake_launch_server(*_args, **_kwargs): assert server_conn._oauth_abort_event.is_set() +@pytest.mark.asyncio +async def test_get_server_startup_timeout_cancels_blocked_lifecycle() -> None: + manager = MCPConnectionManager(server_registry=cast("Any", _DummyRegistry())) + entered = asyncio.Event() + cancelled = asyncio.Event() + + class HangingTransportContext: + async def __aenter__(self): + entered.set() + try: + await asyncio.Event().wait() + except BaseException: + cancelled.set() + raise + + async def __aexit__(self, exc_type, exc, tb): + return None + + server_conn = ServerConnection( + server_name="demo", + server_config=MCPServerSettings( + name="demo", + transport="http", + url="http://127.0.0.1:9/mcp", + ), + transport_context_factory=HangingTransportContext, + client_session_factory=lambda *_args, **_kwargs: object(), + ) + + async def _fake_launch_server(*_args, **_kwargs): + manager.running_servers["demo"] = server_conn + asyncio.create_task(_server_lifecycle_task(server_conn)) + await entered.wait() + return server_conn + + manager.launch_server = _fake_launch_server # type: ignore[method-assign] + + with pytest.raises(ServerInitializationError): + await manager.get_server( + "demo", + client_session_factory=lambda *_args, **_kwargs: object(), + startup_timeout_seconds=0.01, + ) + + await asyncio.wait_for(cancelled.wait(), timeout=1.0) + assert "demo" not in manager.running_servers + assert server_conn._shutdown_event.is_set() + assert server_conn._oauth_abort_event.is_set() + + @pytest.mark.asyncio async def test_get_server_retries_with_oauth_after_401_startup() -> None: manager = MCPConnectionManager(server_registry=cast("Any", _DummyRegistry())) diff --git a/tests/unit/fast_agent/privacy/test_privacy_filter_onnx.py b/tests/unit/fast_agent/privacy/test_privacy_filter_onnx.py index ffbf8f681..ff7ddab79 100644 --- a/tests/unit/fast_agent/privacy/test_privacy_filter_onnx.py +++ b/tests/unit/fast_agent/privacy/test_privacy_filter_onnx.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json from dataclasses import dataclass import pytest from fast_agent.privacy.privacy_filter_onnx import ( OpenAIPrivacyFilterOnnxSanitizer, + _load_viterbi_transition_biases, _merge_spans, _provider_status_message, _replace_spans, @@ -141,6 +143,31 @@ def test_replace_spans_handles_many_redactions_in_linear_pass() -> None: assert redacted == "a b c d" +def test_load_viterbi_transition_biases_reads_default_operating_point(tmp_path) -> None: + path = tmp_path / "viterbi_calibration.json" + path.write_text( + json.dumps( + { + "operating_points": { + "default": { + "biases": { + "transition_bias_background_to_start": 1.25, + "transition_bias_inside_to_end": -0.5, + } + } + } + } + ), + encoding="utf-8", + ) + + biases = _load_viterbi_transition_biases(path) + + assert biases["transition_bias_background_to_start"] == 1.25 + assert biases["transition_bias_inside_to_end"] == -0.5 + assert biases["transition_bias_background_stay"] == 0.0 + + def test_resolve_onnx_execution_providers_prefers_cuda_for_auto() -> None: providers = _resolve_onnx_execution_providers( available_providers=["CPUExecutionProvider", "CUDAExecutionProvider"], diff --git a/tests/unit/fast_agent/privacy/test_viterbi.py b/tests/unit/fast_agent/privacy/test_viterbi.py index b7aac5751..cb67a34ca 100644 --- a/tests/unit/fast_agent/privacy/test_viterbi.py +++ b/tests/unit/fast_agent/privacy/test_viterbi.py @@ -68,6 +68,20 @@ def test_numpy_viterbi_respects_bioes_constraints() -> None: assert previous_kind == kind +def test_numpy_viterbi_applies_transition_biases() -> None: + labels = ["O", "B-private_email", "E-private_email", "S-private_email"] + logits = np.zeros((2, len(labels)), dtype=np.float32) + tables = build_viterbi_tables( + labels, + np, + transition_biases={"transition_bias_inside_to_end": 5.0}, + ) + + path = constrained_viterbi_np(logits, tables, np) + + assert [labels[index] for index in path] == ["B-private_email", "E-private_email"] + + def test_token_spans_from_path_yields_expected_spans() -> None: path = [ _LABELS.index("O"), diff --git a/tests/unit/fast_agent/session/test_hydrator.py b/tests/unit/fast_agent/session/test_hydrator.py index 6d52d8bc6..da2858306 100644 --- a/tests/unit/fast_agent/session/test_hydrator.py +++ b/tests/unit/fast_agent/session/test_hydrator.py @@ -379,6 +379,7 @@ async def test_hydrate_session_restores_runtime_state_and_replaces_usage( history_file=history_path.name, resolved_prompt="Stored foo prompt", model="sonnet-4", + model_spec="anthropic.sonnet-4?reasoning=high", provider="anthropic", request_settings=SessionRequestSettingsSnapshot( max_tokens=2048, @@ -425,8 +426,8 @@ def _fake_rehydrate_usage(agent: _Agent, path): assert result.active_agent == "foo" assert result.usage_notices == ["usage restored"] assert runtime_foo.instruction == "Stored foo prompt" - assert runtime_foo.config.model == "anthropic.sonnet-4" - assert runtime_foo.model_updates == ["anthropic.sonnet-4"] + assert runtime_foo.config.model == "anthropic.sonnet-4?reasoning=high" + assert runtime_foo.model_updates == ["anthropic.sonnet-4?reasoning=high"] assert params.maxTokens == 2048 assert params.temperature == 0.3 assert params.use_history is True diff --git a/tests/unit/fast_agent/session/test_session_manager.py b/tests/unit/fast_agent/session/test_session_manager.py index 3ba113f38..318913f91 100644 --- a/tests/unit/fast_agent/session/test_session_manager.py +++ b/tests/unit/fast_agent/session/test_session_manager.py @@ -96,7 +96,9 @@ def test_prune_sessions_skips_pinned(tmp_path) -> None: reset_session_manager() -def test_get_session_manager_normalizes_relative_environment_dir(tmp_path) -> None: +def test_get_session_manager_resolves_relative_environment_dir_without_mutating_env( + tmp_path, +) -> None: original_env = os.environ.get("ENVIRONMENT_DIR") first_cwd = tmp_path / "first" second_cwd = tmp_path / "second" @@ -108,13 +110,12 @@ def test_get_session_manager_normalizes_relative_environment_dir(tmp_path) -> No try: manager_first = get_session_manager(cwd=first_cwd) - normalized_env = os.environ.get("ENVIRONMENT_DIR") - assert normalized_env is not None - assert normalized_env == str((first_cwd / ".dev").resolve()) + assert os.environ.get("ENVIRONMENT_DIR") == ".dev" manager_second = get_session_manager(cwd=second_cwd) - assert manager_second is manager_first - assert manager_second.base_dir == (first_cwd / ".dev" / "sessions").resolve() + assert manager_second is not manager_first + assert manager_first.base_dir == (first_cwd / ".dev" / "sessions").resolve() + assert manager_second.base_dir == (second_cwd / ".dev" / "sessions").resolve() assert manager_second.workspace_dir == second_cwd.resolve() finally: reset_session_manager() diff --git a/tests/unit/fast_agent/session/test_trace_exporter.py b/tests/unit/fast_agent/session/test_trace_exporter.py index adfd8d1ce..85be81e29 100644 --- a/tests/unit/fast_agent/session/test_trace_exporter.py +++ b/tests/unit/fast_agent/session/test_trace_exporter.py @@ -19,7 +19,12 @@ TextResourceContents, ) -from fast_agent.constants import FAST_AGENT_USAGE +from fast_agent.constants import ( + ANTHROPIC_SERVER_TOOLS_CHANNEL, + FAST_AGENT_TIMING, + FAST_AGENT_USAGE, + OPENAI_ASSISTANT_MESSAGE_ITEMS, +) from fast_agent.mcp.prompt_message_extended import PromptMessageExtended from fast_agent.mcp.prompt_serialization import save_json from fast_agent.privacy.sanitizer import PrivacyFilterModelInfo, RedactionSpan, SanitizedText @@ -183,7 +188,7 @@ def test_session_trace_exporter_writes_codex_trace(tmp_path: Path) -> None: } assert records[2]["type"] == "event_msg" assert "timestamp" not in records[2] - assert records[2]["payload"]["type"] == "turn_started" + assert records[2]["payload"]["type"] == "task_started" assert "started_at" not in records[2]["payload"] assert records[3]["type"] == "event_msg" assert "timestamp" not in records[3] @@ -219,7 +224,7 @@ def test_session_trace_exporter_writes_codex_trace(tmp_path: Path) -> None: assert "phase" not in records[7]["payload"] assert records[8]["type"] == "event_msg" assert "timestamp" not in records[8] - assert records[8]["payload"]["type"] == "turn_complete" + assert records[8]["payload"]["type"] == "task_complete" assert records[8]["payload"]["last_agent_message"] == "done" @@ -260,8 +265,8 @@ def test_session_trace_exporter_context_window_falls_back_to_model( ] assert records[0]["payload"]["model_spec"] == "custom-overlay" - assert records[1]["payload"]["type"] == "turn_started" - assert records[1]["payload"]["model_context_window"] == 400000 + assert records[1]["payload"]["type"] == "task_started" + assert records[1]["payload"]["model_context_window"] == 272000 assert records[3]["payload"]["model"] == "gpt-5.4" assert records[3]["payload"]["model_spec"] == "custom-overlay" @@ -328,6 +333,169 @@ def test_session_trace_exporter_preserves_assistant_commentary_phase(tmp_path: P ] +def test_session_trace_exporter_preserves_raw_openai_assistant_message_items( + tmp_path: Path, +) -> None: + manager = _build_manager(tmp_path) + session_id = "2604201303-raw-items" + session_dir = manager.base_dir / session_id + session_dir.mkdir(parents=True) + messages = [ + PromptMessageExtended( + role="user", + content=[TextContent(type="text", text="hello")], + ), + PromptMessageExtended( + role="assistant", + content=[TextContent(type="text", text="Final answer.")], + channels={ + OPENAI_ASSISTANT_MESSAGE_ITEMS: [ + TextContent( + type="text", + text=json.dumps( + { + "type": "message", + "role": "assistant", + "phase": "commentary", + "content": [ + {"type": "output_text", "text": "Checking the repo."} + ], + } + ), + ), + TextContent( + type="text", + text=json.dumps( + { + "type": "message", + "role": "assistant", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Final answer."}], + } + ), + ), + ] + }, + stop_reason=LlmStopReason.END_TURN, + ), + ] + save_json(messages, str(session_dir / "history_dev.json")) + _write_session_snapshot( + session_dir, + session_id=session_id, + active_agent="dev", + agents={"dev": SessionAgentSnapshot(history_file="history_dev.json")}, + ) + + exporter = SessionTraceExporter(session_manager=manager) + exporter.export( + ExportRequest( + target=session_dir, + agent_name="dev", + output_path=tmp_path / "trace.jsonl", + ) + ) + + records = [ + json.loads(line) + for line in (tmp_path / "trace.jsonl").read_text(encoding="utf-8").splitlines() + ] + assistant_messages = [ + record["payload"] + for record in records + if record["type"] == "response_item" + and record["payload"].get("type") == "message" + and record["payload"].get("role") == "assistant" + ] + + assert assistant_messages == [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Checking the repo."}], + "phase": "commentary", + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Final answer."}], + "phase": "final_answer", + }, + ] + + +def test_session_trace_exporter_exports_server_web_search_as_codex_call( + tmp_path: Path, +) -> None: + manager = _build_manager(tmp_path) + session_id = "2604201303-web-search" + session_dir = manager.base_dir / session_id + session_dir.mkdir(parents=True) + messages = [ + PromptMessageExtended( + role="user", + content=[TextContent(type="text", text="look this up")], + ), + PromptMessageExtended( + role="assistant", + content=[TextContent(type="text", text="I found a result.")], + channels={ + ANTHROPIC_SERVER_TOOLS_CHANNEL: [ + TextContent( + type="text", + text=json.dumps( + { + "type": "server_tool_use", + "id": "ws_1", + "name": "web_search", + "status": "completed", + "input": {"query": "fast-agent trace viewer"}, + } + ), + ) + ] + }, + stop_reason=LlmStopReason.END_TURN, + ), + ] + save_json(messages, str(session_dir / "history_dev.json")) + _write_session_snapshot( + session_dir, + session_id=session_id, + active_agent="dev", + agents={"dev": SessionAgentSnapshot(history_file="history_dev.json")}, + ) + + exporter = SessionTraceExporter(session_manager=manager) + exporter.export( + ExportRequest( + target=session_dir, + agent_name="dev", + output_path=tmp_path / "trace.jsonl", + ) + ) + + records = [ + json.loads(line) + for line in (tmp_path / "trace.jsonl").read_text(encoding="utf-8").splitlines() + ] + web_search_calls = [ + record["payload"] + for record in records + if record["type"] == "response_item" + and record["payload"].get("type") == "web_search_call" + ] + + assert web_search_calls == [ + { + "type": "web_search_call", + "id": "ws_1", + "status": "completed", + "action": {"type": "search", "query": "fast-agent trace viewer"}, + } + ] + + def test_session_trace_exporter_uses_workspace_dir_for_relative_output_paths( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -453,7 +621,7 @@ def test_session_trace_exporter_uses_message_timestamps_for_turn_date(tmp_path: ] assert records[0]["payload"]["timestamp"] == "2026-04-20T13:03:00.000Z" - assert records[1]["payload"]["type"] == "turn_started" + assert records[1]["payload"]["type"] == "task_started" assert records[1]["payload"]["started_at"] == int(turn_started_at.timestamp()) assert records[1]["timestamp"] == "2026-04-22T09:15:00.000Z" assert records[2]["payload"]["type"] == "user_message" @@ -465,6 +633,70 @@ def test_session_trace_exporter_uses_message_timestamps_for_turn_date(tmp_path: assert records[4]["timestamp"] == "2026-04-22T09:15:00.000Z" assert records[5]["payload"]["role"] == "assistant" assert records[5]["timestamp"] == "2026-04-22T09:15:05.000Z" + assert records[6]["payload"]["type"] == "task_complete" + assert records[6]["payload"]["completed_at"] == int( + (turn_started_at + timedelta(seconds=5)).timestamp() + ) + assert records[6]["payload"]["duration_ms"] == 5000 + assert records[6]["timestamp"] == "2026-04-22T09:15:05.000Z" + + +def test_session_trace_exporter_adds_turn_timing_from_fast_agent_channels( + tmp_path: Path, +) -> None: + manager = _build_manager(tmp_path) + session_id = "2604201303-timing" + session_dir = manager.base_dir / session_id + session_dir.mkdir(parents=True) + messages = [ + PromptMessageExtended( + role="user", + content=[TextContent(type="text", text="hello")], + ), + PromptMessageExtended( + role="assistant", + content=[TextContent(type="text", text="done")], + channels={ + FAST_AGENT_TIMING: [ + TextContent( + type="text", + text='{"duration_ms": 1234.56, "ttft_ms": 321.4}', + ) + ] + }, + stop_reason=LlmStopReason.END_TURN, + ), + ] + _write_history_with_timestamps( + session_dir / "history_dev.json", + messages=messages, + timestamps=[None, None], + ) + _write_session_snapshot( + session_dir, + session_id=session_id, + active_agent="dev", + agents={"dev": SessionAgentSnapshot(history_file="history_dev.json")}, + ) + + exporter = SessionTraceExporter(session_manager=manager) + exporter.export( + ExportRequest( + target=session_dir, + agent_name="dev", + output_path=tmp_path / "trace.jsonl", + ) + ) + + records = [ + json.loads(line) + for line in (tmp_path / "trace.jsonl").read_text(encoding="utf-8").splitlines() + ] + + turn_complete = records[-1]["payload"] + assert turn_complete["type"] == "task_complete" + assert turn_complete["duration_ms"] == 1235 + assert turn_complete["time_to_first_token_ms"] == 321 def test_session_trace_exporter_writes_native_codex_tool_items(tmp_path: Path) -> None: @@ -540,7 +772,7 @@ def test_session_trace_exporter_writes_native_codex_tool_items(tmp_path: Path) - ] assert records[1]["type"] == "event_msg" - assert records[1]["payload"]["type"] == "turn_started" + assert records[1]["payload"]["type"] == "task_started" assert records[2]["type"] == "turn_context" assert records[3]["type"] == "response_item" assert records[3]["payload"] == { @@ -563,7 +795,7 @@ def test_session_trace_exporter_writes_native_codex_tool_items(tmp_path: Path) - "status": "success", } assert records[6]["type"] == "event_msg" - assert records[6]["payload"]["type"] == "turn_complete" + assert records[6]["payload"]["type"] == "task_complete" assert records[6]["payload"]["last_agent_message"] == "Using tools" @@ -673,7 +905,7 @@ def test_session_trace_exporter_applies_privacy_sanitizer_to_codex_text( ) assert tool_output["output"] == " result" turn_complete = next( - payload for payload in reversed(payloads) if payload.get("type") == "turn_complete" + payload for payload in reversed(payloads) if payload.get("type") == "task_complete" ) assert turn_complete["last_agent_message"] == "Using tools for " @@ -1036,7 +1268,7 @@ def test_session_trace_exporter_uses_usage_metadata_for_model_and_token_count( assert records[0]["payload"]["model_provider"] == "codexresponses" assert records[0]["payload"]["model_spec"] == "gpt-5.3-codex?service_tier=flex" assert records[1]["type"] == "event_msg" - assert records[1]["payload"]["type"] == "turn_started" + assert records[1]["payload"]["type"] == "task_started" assert records[1]["payload"]["model_context_window"] == 400000 assert records[3]["type"] == "turn_context" assert records[3]["payload"]["model"] == "gpt-5.3-codex" @@ -1045,6 +1277,13 @@ def test_session_trace_exporter_uses_usage_metadata_for_model_and_token_count( assert records[6]["payload"] == { "type": "token_count", "info": { + "total_token_usage": { + "input_tokens": 120, + "cached_input_tokens": 12, + "output_tokens": 30, + "reasoning_output_tokens": 7, + "total_tokens": 150, + }, "last_token_usage": { "input_tokens": 120, "cached_input_tokens": 12, diff --git a/tests/unit/fast_agent/skills/test_marketplace_parsing.py b/tests/unit/fast_agent/skills/test_marketplace_parsing.py index 2ee30c307..55f61aa20 100644 --- a/tests/unit/fast_agent/skills/test_marketplace_parsing.py +++ b/tests/unit/fast_agent/skills/test_marketplace_parsing.py @@ -46,3 +46,25 @@ def test_parse_marketplace_payload_expands_plugin_bundle_entries() -> None: ] assert all(skill.repo_url == "https://github.com/example/skills" for skill in skills) assert all(skill.bundle_name == "Useful Bundle" for skill in skills) + + +def test_parse_marketplace_payload_does_not_label_unexpanded_plugin_as_bundle() -> None: + payload = { + "plugins": [ + { + "name": "session-investigator", + "description": "Investigate sessions", + "source": { + "source": "github", + "repo": "example/skills", + "path": "skills/session-investigator", + }, + } + ], + } + + skills = parse_marketplace_payload(payload) + + assert len(skills) == 1 + assert skills[0].name == "session-investigator" + assert skills[0].bundle_name is None diff --git a/tests/unit/fast_agent/skills/test_operations.py b/tests/unit/fast_agent/skills/test_operations.py index 7a935da40..a48057f82 100644 --- a/tests/unit/fast_agent/skills/test_operations.py +++ b/tests/unit/fast_agent/skills/test_operations.py @@ -78,6 +78,55 @@ def test_operations_scan_local_registry_and_install_into_managed_path(tmp_path: assert (managed_root / "alpha" / "SKILL.md").exists() +def test_operations_expands_plugin_source_with_multiple_nested_skills(tmp_path: Path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + plugin_dir = repo / "plugins" / "mcp-apps" + for name in ("create-mcp-app", "convert-web-app"): + skill_dir = plugin_dir / "skills" / name + skill_dir.mkdir(parents=True) + skill_dir.joinpath("SKILL.md").write_text( + f"---\nname: {name}\ndescription: {name} skill\n---\n\nBody.\n", + encoding="utf-8", + ) + + registry_path = repo / ".claude-plugin" / "marketplace.json" + registry_path.parent.mkdir(parents=True) + registry_path.write_text( + json.dumps( + { + "plugins": [ + { + "name": "mcp-apps", + "description": "Skills for MCP Apps development", + "source": "./plugins/mcp-apps", + } + ] + } + ), + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "initial") + + skills, resolved_source = asyncio.run( + fetch_marketplace_skills_with_source(registry_path.as_posix()) + ) + + assert resolved_source == registry_path.as_posix() + assert [skill.name for skill in skills] == ["convert-web-app", "create-mcp-app"] + assert [skill.repo_path for skill in skills] == [ + "plugins/mcp-apps/skills/convert-web-app", + "plugins/mcp-apps/skills/create-mcp-app", + ] + assert all(skill.bundle_name == "mcp-apps" for skill in skills) + + managed_root = tmp_path / "managed" + install_marketplace_skill_sync(skills[0], managed_root) + + assert (managed_root / "convert-web-app" / "SKILL.md").exists() + + def test_install_skill_rolls_back_when_installed_skill_cannot_be_reloaded(tmp_path: Path) -> None: repo = tmp_path / "repo" _init_repo(repo) diff --git a/tests/unit/fast_agent/test_config_model_layering.py b/tests/unit/fast_agent/test_config_model_layering.py index 3b37ff8e2..8c2e09087 100644 --- a/tests/unit/fast_agent/test_config_model_layering.py +++ b/tests/unit/fast_agent/test_config_model_layering.py @@ -6,7 +6,7 @@ import yaml import fast_agent.config as config_module -from fast_agent.config import get_settings +from fast_agent.config import Settings, get_settings, update_global_settings def _write_yaml(path: Path, payload: dict) -> None: @@ -15,6 +15,32 @@ def _write_yaml(path: Path, payload: dict) -> None: yaml.safe_dump(payload, handle, sort_keys=False) +def test_get_settings_preserves_manually_installed_global_settings( + tmp_path: Path, + monkeypatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _write_yaml(workspace / ".fast-agent" / "fast-agent.yaml", {"default_model": "disk-default"}) + monkeypatch.chdir(workspace) + + previous_settings = config_module._settings + try: + manual_settings = Settings( + default_model="manual-default", + environment_dir=str(tmp_path / ".manual-fast-agent"), + ) + update_global_settings(manual_settings) + + settings = get_settings() + + assert settings is manual_settings + assert settings.default_model == "manual-default" + assert settings.environment_dir == str(tmp_path / ".manual-fast-agent") + finally: + config_module._settings = previous_settings + + def test_get_settings_prefers_env_config_over_cwd_and_legacy(tmp_path: Path) -> None: workspace = tmp_path / "workspace" nested = workspace / "child" @@ -47,6 +73,119 @@ def test_get_settings_prefers_env_config_over_cwd_and_legacy(tmp_path: Path) -> os.environ["ENVIRONMENT_DIR"] = previous_env_dir +def test_get_settings_env_dir_argument_wins_over_fast_agent_home( + tmp_path: Path, + monkeypatch, +) -> None: + workspace = tmp_path / "workspace" + fast_agent_home = workspace / ".from-fast-agent-home" + cli_env = workspace / ".from-cli-env" + workspace.mkdir(parents=True) + + _write_yaml(fast_agent_home / "fast-agent.yaml", {"default_model": "wrong-home"}) + _write_yaml(cli_env / "fast-agent.yaml", {"default_model": "right-home"}) + monkeypatch.setenv("FAST_AGENT_HOME", str(fast_agent_home)) + monkeypatch.chdir(workspace) + + previous_settings = config_module._settings + try: + config_module._settings = None + + settings = get_settings(env_dir=cli_env) + + assert settings.default_model == "right-home" + assert settings._config_file == str(cli_env / "fast-agent.yaml") + finally: + config_module._settings = previous_settings + + +def test_get_settings_recomputes_when_env_dir_argument_changes( + tmp_path: Path, + monkeypatch, +) -> None: + workspace = tmp_path / "workspace" + default_env = workspace / ".fast-agent" + cli_env = workspace / ".custom-env" + workspace.mkdir(parents=True) + + _write_yaml(default_env / "fast-agent.yaml", {"default_model": "default-env"}) + _write_yaml(cli_env / "fast-agent.yaml", {"default_model": "cli-env"}) + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) + monkeypatch.chdir(workspace) + + previous_settings = config_module._settings + try: + config_module._settings = None + + cached_settings = get_settings() + selected_settings = get_settings(env_dir=cli_env) + + assert cached_settings.default_model == "default-env" + assert selected_settings.default_model == "cli-env" + assert selected_settings._config_file == str(cli_env / "fast-agent.yaml") + finally: + config_module._settings = previous_settings + + +def test_get_settings_recomputes_when_env_dir_override_removed( + tmp_path: Path, + monkeypatch, +) -> None: + workspace = tmp_path / "workspace" + default_env = workspace / ".fast-agent" + cli_env = workspace / ".custom-env" + workspace.mkdir(parents=True) + + _write_yaml(default_env / "fast-agent.yaml", {"default_model": "default-env"}) + _write_yaml(cli_env / "fast-agent.yaml", {"default_model": "cli-env"}) + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) + monkeypatch.chdir(workspace) + + previous_settings = config_module._settings + try: + config_module._settings = None + + cli_settings = get_settings(env_dir=cli_env) + default_settings = get_settings() + + assert cli_settings.default_model == "cli-env" + assert default_settings.default_model == "default-env" + assert default_settings._config_file == str(default_env / "fast-agent.yaml") + finally: + config_module._settings = previous_settings + + +def test_get_settings_recomputes_when_noenv_argument_changes( + tmp_path: Path, + monkeypatch, +) -> None: + workspace = tmp_path / "workspace" + env_dir = workspace / ".fast-agent" + workspace.mkdir(parents=True) + + _write_yaml(env_dir / "fast-agent.yaml", {"default_model": "env-default"}) + _write_yaml(workspace / "fast-agent.yaml", {"default_model": "cwd-default"}) + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) + monkeypatch.chdir(workspace) + + previous_settings = config_module._settings + try: + config_module._settings = None + + cached_settings = get_settings() + selected_settings = get_settings(noenv=True) + + assert cached_settings.default_model == "env-default" + assert selected_settings.default_model == "cwd-default" + assert selected_settings._fast_agent_home is None + assert selected_settings._fast_agent_noenv is True + finally: + config_module._settings = previous_settings + + def test_get_settings_prefers_cwd_config_when_env_missing(tmp_path: Path) -> None: workspace = tmp_path / "workspace" nested = workspace / "child" @@ -77,7 +216,7 @@ def test_get_settings_prefers_cwd_config_when_env_missing(tmp_path: Path) -> Non os.environ["ENVIRONMENT_DIR"] = previous_env_dir -def test_get_settings_falls_back_to_parent_config_as_legacy_lookup(tmp_path: Path) -> None: +def test_get_settings_ignores_parent_config(tmp_path: Path) -> None: workspace = tmp_path / "workspace" nested = workspace / "child" / "grandchild" workspace.mkdir(parents=True) @@ -95,8 +234,8 @@ def test_get_settings_falls_back_to_parent_config_as_legacy_lookup(tmp_path: Pat settings = get_settings() - assert settings.default_model == "legacy-default" - assert settings._config_file == str(workspace / "fastagent.config.yaml") + assert settings.default_model is None + assert settings._config_file is None finally: os.chdir(previous_cwd) config_module._settings = previous_settings @@ -106,7 +245,7 @@ def test_get_settings_falls_back_to_parent_config_as_legacy_lookup(tmp_path: Pat os.environ["ENVIRONMENT_DIR"] = previous_env_dir -def test_get_settings_keeps_secrets_last_with_env_cwd_legacy_discovery(tmp_path: Path) -> None: +def test_get_settings_pairs_secrets_with_selected_config_directory(tmp_path: Path) -> None: workspace = tmp_path / "workspace" nested = workspace / "child" env_dir = nested / ".fast-agent" @@ -127,8 +266,8 @@ def test_get_settings_keeps_secrets_last_with_env_cwd_legacy_discovery(tmp_path: settings = get_settings() - assert settings.default_model == "secret-default" - assert settings._secrets_file == str(env_dir / "fastagent.secrets.yaml") + assert settings.default_model == "cwd-default" + assert settings._secrets_file is None finally: os.chdir(previous_cwd) config_module._settings = previous_settings diff --git a/tests/unit/fast_agent/test_config_plugin_commands.py b/tests/unit/fast_agent/test_config_plugin_commands.py new file mode 100644 index 000000000..334cf45ed --- /dev/null +++ b/tests/unit/fast_agent/test_config_plugin_commands.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fast_agent.config import get_settings + +if TYPE_CHECKING: + from pathlib import Path + + +def test_settings_parses_global_plugin_commands(tmp_path: Path) -> None: + config_path = tmp_path / "fastagent.config.yaml" + config_path.write_text( + "\n".join( + [ + "commands:", + " draft-next:", + " description: Draft the next user message", + " input_hint: \"[format]\"", + " handler: \"commands.py:draft_next\"", + " key: \"c-x d\"", + ] + ), + encoding="utf-8", + ) + + settings = get_settings(config_path) + + assert settings.commands is not None + assert settings.commands["draft-next"].description == "Draft the next user message" + assert settings.commands["draft-next"].handler == "commands.py:draft_next" + assert settings.commands["draft-next"].input_hint == "[format]" + assert settings.commands["draft-next"].key == "c-x d" diff --git a/tests/unit/fast_agent/test_home.py b/tests/unit/fast_agent/test_home.py new file mode 100644 index 000000000..d760ba09e --- /dev/null +++ b/tests/unit/fast_agent/test_home.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from fast_agent.core.exceptions import ConfigFileError, FastAgentError +from fast_agent.home import ( + AmbiguousConfigFilesError, + AmbiguousSecretsFilesError, + FastAgentHome, + build_child_environment, + discover_config_files, + find_config_in_directory, + find_secrets_in_directory, + resolve_fast_agent_home, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def touch(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("x: y\n", encoding="utf-8") + return path + + +def test_resolve_home_precedence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAST_AGENT_HOME", "from-fast-agent-home") + monkeypatch.setenv("ENVIRONMENT_DIR", "from-legacy-env") + + home = resolve_fast_agent_home(cwd=tmp_path, cli_override="from-cli") + + assert home == FastAgentHome((tmp_path / "from-cli").resolve(), "cli") + + +def test_resolve_home_uses_fast_agent_home_before_legacy_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FAST_AGENT_HOME", "from-fast-agent-home") + monkeypatch.setenv("ENVIRONMENT_DIR", "from-legacy-env") + + home = resolve_fast_agent_home(cwd=tmp_path) + + assert home == FastAgentHome((tmp_path / "from-fast-agent-home").resolve(), "FAST_AGENT_HOME") + + +def test_resolve_home_uses_legacy_env_and_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.setenv("ENVIRONMENT_DIR", "legacy-home") + + legacy_home = resolve_fast_agent_home(cwd=tmp_path) + assert legacy_home == FastAgentHome((tmp_path / "legacy-home").resolve(), "ENVIRONMENT_DIR") + + monkeypatch.delenv("ENVIRONMENT_DIR") + default_home = resolve_fast_agent_home(cwd=tmp_path) + assert default_home == FastAgentHome((tmp_path / ".fast-agent").resolve(), "default") + + +def test_noenv_disables_home_resolution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FAST_AGENT_HOME", "ignored") + monkeypatch.setenv("ENVIRONMENT_DIR", "ignored") + + assert resolve_fast_agent_home(cwd=tmp_path, noenv=True) is None + + +def test_discovers_preferred_config_in_home(tmp_path: Path) -> None: + home_path = tmp_path / ".fast-agent" + config = touch(home_path / "fast-agent.yaml") + secrets = touch(home_path / "fast-agent.secrets.yaml") + home = FastAgentHome(home_path.resolve(), "default") + + result = discover_config_files(cwd=tmp_path, home=home) + + assert result.home == home + assert result.config_path == config.resolve() + assert result.secrets_path == secrets.resolve() + assert result.config_source == "home" + assert result.secrets_source == "same_dir" + + +def test_discovers_preferred_config_in_cwd_without_home(tmp_path: Path) -> None: + config = touch(tmp_path / "fast-agent.yaml") + + result = discover_config_files(cwd=tmp_path, home=None) + + assert result.config_path == config.resolve() + assert result.config_source == "cwd" + + +def test_discovers_legacy_and_transitional_aliases(tmp_path: Path) -> None: + legacy_home = tmp_path / "legacy-home" + transitional_home = tmp_path / "transitional-home" + legacy_config = touch(legacy_home / "fastagent.config.yaml") + transitional_config = touch(transitional_home / "fast-agent.config.yaml") + + legacy_result = discover_config_files( + cwd=tmp_path, + home=FastAgentHome(legacy_home.resolve(), "cli"), + ) + transitional_result = discover_config_files( + cwd=tmp_path, + home=FastAgentHome(transitional_home.resolve(), "cli"), + ) + + assert legacy_result.config_path == legacy_config.resolve() + assert transitional_result.config_path == transitional_config.resolve() + + +def test_parent_directories_are_ignored(tmp_path: Path) -> None: + parent_config = touch(tmp_path / "fast-agent.yaml") + nested = tmp_path / "app" / "nested" + nested.mkdir(parents=True) + + result = discover_config_files(cwd=nested, home=None) + + assert result.config_path is None + assert result.secrets_path is None + assert parent_config.exists() + + +def test_same_directory_config_ambiguity_errors(tmp_path: Path) -> None: + touch(tmp_path / "fast-agent.yaml") + touch(tmp_path / "fastagent.config.yaml") + + with pytest.raises(AmbiguousConfigFilesError) as exc_info: + find_config_in_directory(tmp_path) + + assert isinstance(exc_info.value, ConfigFileError) + assert isinstance(exc_info.value, FastAgentError) + assert exc_info.value.directory == tmp_path.resolve() + assert [path.name for path in exc_info.value.candidates] == [ + "fast-agent.yaml", + "fastagent.config.yaml", + ] + + +def test_same_directory_secrets_ambiguity_errors(tmp_path: Path) -> None: + touch(tmp_path / "fast-agent.secrets.yaml") + touch(tmp_path / "fastagent.secrets.yaml") + + with pytest.raises(AmbiguousSecretsFilesError) as exc_info: + find_secrets_in_directory(tmp_path) + + assert exc_info.value.directory == tmp_path.resolve() + assert [path.name for path in exc_info.value.candidates] == [ + "fast-agent.secrets.yaml", + "fastagent.secrets.yaml", + ] + + +def test_explicit_config_path_is_exact_and_uses_same_directory_secrets( + tmp_path: Path, +) -> None: + cwd = tmp_path / "cwd" + cwd.mkdir() + config_dir = tmp_path / "config-dir" + config = touch(config_dir / "custom.yaml") + secrets = touch(config_dir / "fast-agent.secrets.yaml") + touch(tmp_path / "fast-agent.secrets.yaml") + + result = discover_config_files(cwd=cwd, home=None, explicit_config_path=config) + + assert result.config_path == config.resolve() + assert result.secrets_path == secrets.resolve() + assert result.config_source == "explicit" + assert result.secrets_source == "same_dir" + + +def test_config_selected_secrets_come_only_from_config_directory(tmp_path: Path) -> None: + home_path = tmp_path / ".fast-agent" + config = touch(home_path / "fast-agent.yaml") + cwd_secrets = touch(tmp_path / "fast-agent.secrets.yaml") + + result = discover_config_files( + cwd=tmp_path, + home=FastAgentHome(home_path.resolve(), "default"), + ) + + assert result.config_path == config.resolve() + assert result.secrets_path is None + assert cwd_secrets.exists() + + +def test_secrets_only_searches_home_then_cwd(tmp_path: Path) -> None: + home_path = tmp_path / ".fast-agent" + home_secrets = touch(home_path / "fast-agent.secrets.yaml") + touch(tmp_path / "fast-agent.secrets.yaml") + + home_result = discover_config_files( + cwd=tmp_path, + home=FastAgentHome(home_path.resolve(), "default"), + ) + cwd_result = discover_config_files(cwd=tmp_path, home=None) + + assert home_result.config_path is None + assert home_result.secrets_path == home_secrets.resolve() + assert home_result.secrets_source == "home" + assert cwd_result.secrets_path == (tmp_path / "fast-agent.secrets.yaml").resolve() + assert cwd_result.secrets_source == "cwd" + + +def test_home_and_cwd_same_directory_is_searched_once(tmp_path: Path) -> None: + config = touch(tmp_path / "fast-agent.yaml") + home = FastAgentHome(tmp_path.resolve(), "cli") + + result = discover_config_files(cwd=tmp_path, home=home) + + assert result.config_path == config.resolve() + assert result.config_source == "home" + + +def test_child_environment_exports_runtime_home_and_legacy_alias(tmp_path: Path) -> None: + env = build_child_environment( + active_home=tmp_path / ".fast-agent", + base={"PATH": "/bin"}, + overrides={"EXTRA": "1"}, + ) + + assert env["PATH"] == "/bin" + assert env["EXTRA"] == "1" + assert env["FAST_AGENT_RUNTIME_ENVIRONMENT"] == str((tmp_path / ".fast-agent").resolve()) + assert env["ENVIRONMENT_DIR"] == str((tmp_path / ".fast-agent").resolve()) + + +def test_noenv_child_environment_strips_runtime_home_aliases(tmp_path: Path) -> None: + env = build_child_environment( + active_home=tmp_path / ".fast-agent", + noenv=True, + base={ + "PATH": "/bin", + "FAST_AGENT_RUNTIME_ENVIRONMENT": "inherited", + "ENVIRONMENT_DIR": "inherited", + }, + overrides={ + "FAST_AGENT_RUNTIME_ENVIRONMENT": "override", + "ENVIRONMENT_DIR": "override", + }, + ) + + assert env == {"PATH": "/bin"} diff --git a/tests/unit/fast_agent/test_paths.py b/tests/unit/fast_agent/test_paths.py new file mode 100644 index 000000000..f6e31aa1b --- /dev/null +++ b/tests/unit/fast_agent/test_paths.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import fast_agent.config as config_module +from fast_agent.config import Settings +from fast_agent.paths import default_skill_paths, resolve_environment_dir, resolve_environment_paths + +if TYPE_CHECKING: + from pathlib import Path + + +def test_resolve_environment_dir_uses_fast_agent_home( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = tmp_path / "home" + monkeypatch.setenv("FAST_AGENT_HOME", str(home)) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) + + assert resolve_environment_dir(Settings(), cwd=tmp_path) == home.resolve() + + +def test_resolve_environment_dir_override_wins_over_fast_agent_home( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FAST_AGENT_HOME", str(tmp_path / "from-home")) + + assert ( + resolve_environment_dir(Settings(), cwd=tmp_path, override=tmp_path / "from-cli") + == (tmp_path / "from-cli").resolve() + ) + + +def test_resolve_environment_dir_settings_environment_dir_wins_over_env_vars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FAST_AGENT_HOME", str(tmp_path / "from-home")) + + assert ( + resolve_environment_dir(Settings(environment_dir="from-settings"), cwd=tmp_path) + == (tmp_path / "from-settings").resolve() + ) + + +def test_resolve_environment_dir_settings_environment_dir_wins_over_cached_home( + tmp_path: Path, +) -> None: + settings = Settings(environment_dir="configured-home") + settings._fast_agent_home = str(tmp_path / ".fast-agent") + + assert resolve_environment_dir(settings, cwd=tmp_path) == ( + tmp_path / "configured-home" + ).resolve() + + +def test_resolve_environment_dir_uses_settings_selected_home( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + settings._fast_agent_home = str(tmp_path / "selected-home") + monkeypatch.setenv("FAST_AGENT_HOME", str(tmp_path / "ambient-home")) + + assert resolve_environment_dir(settings, cwd=tmp_path) == (tmp_path / "selected-home").resolve() + + +def test_resolve_environment_paths_uses_get_settings_env_dir( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected_home = tmp_path / "selected-home" + ambient_home = tmp_path / "ambient-home" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("FAST_AGENT_HOME", str(ambient_home)) + config_module._settings = None + + try: + settings = config_module.get_settings(env_dir=selected_home) + + assert resolve_environment_paths(settings, cwd=tmp_path).root == selected_home.resolve() + finally: + config_module._settings = None + + +def test_resolve_environment_paths_rejects_noenv_settings( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + settings._fast_agent_noenv = True + monkeypatch.setenv("FAST_AGENT_HOME", str(tmp_path / "ambient-home")) + + with pytest.raises(ValueError, match="fast-agent home is disabled"): + resolve_environment_paths(settings, cwd=tmp_path) + + +def test_default_skill_paths_use_settings_selected_home( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + settings._fast_agent_home = str(tmp_path / "selected-home") + monkeypatch.setenv("FAST_AGENT_HOME", str(tmp_path / "ambient-home")) + + paths = default_skill_paths(settings, cwd=tmp_path) + + assert paths[0] == (tmp_path / "selected-home" / "skills").resolve() + assert (tmp_path / "ambient-home" / "skills").resolve() not in paths + + +def test_default_skill_paths_skip_home_when_noenv( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + settings._fast_agent_noenv = True + monkeypatch.setenv("FAST_AGENT_HOME", str(tmp_path / "ambient-home")) + + paths = default_skill_paths(settings, cwd=tmp_path) + + assert (tmp_path / ".fast-agent" / "skills").resolve() not in paths + assert (tmp_path / "ambient-home" / "skills").resolve() not in paths + assert paths == [ + (tmp_path / ".agents" / "skills").resolve(), + (tmp_path / ".claude" / "skills").resolve(), + ] diff --git a/tests/unit/fast_agent/tools/test_shell_runtime.py b/tests/unit/fast_agent/tools/test_shell_runtime.py index 03e13321c..697a160aa 100644 --- a/tests/unit/fast_agent/tools/test_shell_runtime.py +++ b/tests/unit/fast_agent/tools/test_shell_runtime.py @@ -1,9 +1,11 @@ import asyncio import logging +import os import platform import signal import subprocess import sys +import time from contextlib import contextmanager from pathlib import Path from typing import Any @@ -147,6 +149,48 @@ def _extract_progress_payloads(logger: RecordingFastLogger) -> list[dict[str, An return payloads +def test_shell_process_plan_exports_runtime_home(tmp_path: Path) -> None: + settings = Settings() + settings._fast_agent_home = str(tmp_path / ".fast-agent") + runtime = ShellRuntime(activation_reason="test", logger=logging.getLogger(__name__), config=settings) + + plan = runtime._build_process_plan(tmp_path) + + assert plan.process_kwargs["env"]["FAST_AGENT_RUNTIME_ENVIRONMENT"] == str( + (tmp_path / ".fast-agent").resolve() + ) + assert plan.process_kwargs["env"]["ENVIRONMENT_DIR"] == str((tmp_path / ".fast-agent").resolve()) + + +def test_shell_process_plan_strips_runtime_home_in_noenv(tmp_path: Path) -> None: + settings = Settings() + settings._fast_agent_home = str(tmp_path / ".fast-agent") + settings._fast_agent_noenv = True + runtime = ShellRuntime(activation_reason="test", logger=logging.getLogger(__name__), config=settings) + + plan = runtime._build_process_plan(tmp_path) + + assert "FAST_AGENT_RUNTIME_ENVIRONMENT" not in plan.process_kwargs["env"] + assert "ENVIRONMENT_DIR" not in plan.process_kwargs["env"] + + +def _terminate_pid(pid_path: Path) -> None: + if not pid_path.exists(): + return + try: + pid = int(pid_path.read_text(encoding="utf-8").strip()) + except ValueError: + return + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.kill(pid, sig) + except ProcessLookupError: + return + except OSError: + return + time.sleep(0.1) + + @pytest.mark.asyncio async def test_execute_simple_command() -> None: """Test that shell runtime can execute a simple cross-platform command.""" @@ -201,11 +245,35 @@ async def test_execute_reports_informative_truncation_summary() -> None: assert result.content is not None assert isinstance(result.content[0], TextContent) text = result.content[0].text - assert "[Output truncated: retained" in text + assert "[Output truncated: showing first" in text assert "Increase shell_execution.output_byte_limit to retain more." in text assert "omitted" in text +@pytest.mark.asyncio +async def test_execute_truncated_result_includes_tail() -> None: + logger = logging.getLogger("shell-runtime-test") + runtime = ShellRuntime( + activation_reason="test", + logger=logger, + timeout_seconds=10, + output_byte_limit=80, + config=Settings(shell_execution=ShellSettings(show_bash=False)), + ) + + script = "for i in range(30): print(f'line-{i:02d}')" + result = await runtime.execute({"command": f"{sys.executable} -c {script!r}"}) + + assert result.content is not None + assert isinstance(result.content[0], TextContent) + text = result.content[0].text + assert "line-00" in text + assert "line-29" in text + assert "last" in text + assert "omitted" in text + assert "process exit code was 0" in text + + @pytest.mark.asyncio async def test_execute_handles_overlong_output_lines_without_timeout() -> None: logger = logging.getLogger("shell-runtime-test") @@ -226,7 +294,147 @@ async def test_execute_handles_overlong_output_lines_without_timeout() -> None: text = result.content[0].text assert "timeout after" not in text assert "process exit code was 0" in text - assert "[Output truncated: retained" in text + assert "[Output truncated: showing first" in text + + +@pytest.mark.asyncio +@pytest.mark.skipif(platform.system() == "Windows", reason="Unix inherited-pipe behavior") +async def test_execute_returns_when_descendant_keeps_pipe_open(tmp_path: Path) -> None: + logger = logging.getLogger("shell-runtime-test") + runtime = ShellRuntime( + activation_reason="test", + logger=logger, + timeout_seconds=10, + config=Settings(shell_execution=ShellSettings(show_bash=False)), + ) + pid_path = tmp_path / "descendant.pid" + script_path = tmp_path / "hold_pipe.py" + script_path.write_text( + "\n".join( + [ + "import subprocess, sys", + "child = subprocess.Popen(", + " [sys.executable, '-c', 'import time; time.sleep(30)'],", + " stdout=sys.stdout,", + " stderr=sys.stderr,", + " start_new_session=True,", + ")", + f"open({str(pid_path)!r}, 'w', encoding='utf-8').write(str(child.pid))", + "print('parent exiting', flush=True)", + ] + ), + encoding="utf-8", + ) + + started = time.monotonic() + try: + result = await runtime.execute({"command": f'"{sys.executable}" "{script_path}"'}) + finally: + _terminate_pid(pid_path) + elapsed = time.monotonic() - started + + assert elapsed < 7 + assert result.isError is False + assert result.content is not None + assert isinstance(result.content[0], TextContent) + text = result.content[0].text + assert "parent exiting" in text + assert "output collection stopped after" in text + assert "process exit code was 0" in text + + +@pytest.mark.asyncio +@pytest.mark.skipif(platform.system() == "Windows", reason="Unix inherited-pipe behavior") +async def test_timeout_with_inherited_pipe_does_not_hang(tmp_path: Path) -> None: + logger = logging.getLogger("shell-runtime-test") + runtime = ShellRuntime( + activation_reason="test", + logger=logger, + timeout_seconds=1, + warning_interval_seconds=10, + config=Settings(shell_execution=ShellSettings(show_bash=False)), + ) + pid_path = tmp_path / "descendant.pid" + script_path = tmp_path / "timeout_hold_pipe.py" + script_path.write_text( + "\n".join( + [ + "import subprocess, sys, time", + "print('before idle timeout', flush=True)", + "child = subprocess.Popen(", + " [sys.executable, '-c', 'import time; time.sleep(30)'],", + " stdout=sys.stdout,", + " stderr=sys.stderr,", + " start_new_session=True,", + ")", + f"open({str(pid_path)!r}, 'w', encoding='utf-8').write(str(child.pid))", + "time.sleep(30)", + ] + ), + encoding="utf-8", + ) + + started = time.monotonic() + try: + result = await runtime.execute({"command": f'"{sys.executable}" "{script_path}"'}) + finally: + _terminate_pid(pid_path) + elapsed = time.monotonic() - started + + assert elapsed < 8 + assert result.isError is True + assert result.content is not None + assert isinstance(result.content[0], TextContent) + text = result.content[0].text + assert "before idle timeout" in text + assert "output collection stopped after" in text + assert "timeout after 1s" in text + + +@pytest.mark.asyncio +async def test_execute_huge_output_exits_cleanly_with_low_byte_limit() -> None: + logger = logging.getLogger("shell-runtime-test") + runtime = ShellRuntime( + activation_reason="test", + logger=logger, + timeout_seconds=10, + output_byte_limit=1024, + config=Settings(shell_execution=ShellSettings(show_bash=False)), + ) + + command = f'"{sys.executable}" -c "import sys; sys.stdout.buffer.write(b\'x\' * 5_000_000)"' + result = await runtime.execute({"command": command}) + + assert result.isError is False + assert result.content is not None + assert isinstance(result.content[0], TextContent) + text = result.content[0].text + assert "process exit code was 0" in text + assert "[Output truncated: showing first" in text + assert len(text.encode("utf-8")) < 5_000 + + +@pytest.mark.asyncio +async def test_drain_output_tasks_propagates_reader_exceptions() -> None: + logger = logging.getLogger("shell-runtime-test") + runtime = ShellRuntime(activation_reason="test", logger=logger) + + class ReaderError(Exception): + pass + + async def fails() -> None: + raise ReaderError("boom") + + async def waits() -> None: + await asyncio.sleep(30) + + pending_task = asyncio.create_task(waits()) + with pytest.raises(ReaderError): + await runtime._drain_output_tasks( + [asyncio.create_task(fails()), pending_task], + timeout_seconds=1, + ) + assert pending_task.cancelled() @pytest.mark.asyncio @@ -421,6 +629,7 @@ async def test_execute_emits_shell_lifecycle_progress_events(monkeypatch: pytest runtime.working_directory = lambda: Path(".") # type: ignore[assignment] process = DummyProcess() + process.returncode = 0 process.stdout = DummyStream([b"hello\n"]) process.stderr = DummyStream([]) @@ -483,7 +692,7 @@ async def fail_shell(*args, **kwargs): assert result.isError is True assert result.content is not None assert isinstance(result.content[0], TextContent) - assert "Command failed to start" in result.content[0].text + assert "Command execution failed" in result.content[0].text progress_payloads = _extract_progress_payloads(logger) assert len(progress_payloads) == 2 diff --git a/tests/unit/fast_agent/ui/test_agent_completer.py b/tests/unit/fast_agent/ui/test_agent_completer.py index 0e3fde9ad..9bdff3696 100644 --- a/tests/unit/fast_agent/ui/test_agent_completer.py +++ b/tests/unit/fast_agent/ui/test_agent_completer.py @@ -1052,6 +1052,8 @@ def test_get_completions_for_mcp_session_jar_suppresses_single_server_noise() -> def test_get_completions_for_mcp_connect_configured_servers(monkeypatch) -> None: + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) settings = Settings( mcp=MCPSettings( servers={ @@ -1076,6 +1078,8 @@ def test_get_completions_for_mcp_connect_configured_servers(monkeypatch) -> None def test_get_completions_for_mcp_connect_configured_url_server_shows_url(monkeypatch) -> None: + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) settings = Settings( mcp=MCPSettings( servers={ @@ -1100,6 +1104,8 @@ def test_get_completions_for_mcp_connect_configured_url_server_shows_url(monkeyp def test_get_completions_for_mcp_connect_shows_target_hint_first(monkeypatch) -> None: + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) settings = Settings( mcp=MCPSettings( servers={ @@ -1120,6 +1126,8 @@ def test_get_completions_for_mcp_connect_shows_target_hint_first(monkeypatch) -> def test_get_completions_for_connect_alias_shows_target_hint_and_servers(monkeypatch) -> None: + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) settings = Settings( mcp=MCPSettings( servers={ @@ -1152,6 +1160,8 @@ def test_get_completions_for_connect_alias_connect_flags() -> None: def test_get_completions_for_skills_remove(monkeypatch): """Test get_completions suggests local skills for /skills remove.""" + monkeypatch.delenv("FAST_AGENT_HOME", raising=False) + monkeypatch.delenv("ENVIRONMENT_DIR", raising=False) with tempfile.TemporaryDirectory() as tmpdir: skills_root = Path(tmpdir) / "skills" _write_skill(skills_root, "alpha") diff --git a/tests/unit/fast_agent/ui/test_interactive_shell.py b/tests/unit/fast_agent/ui/test_interactive_shell.py index 324cc18a3..13586f7a0 100644 --- a/tests/unit/fast_agent/ui/test_interactive_shell.py +++ b/tests/unit/fast_agent/ui/test_interactive_shell.py @@ -34,7 +34,7 @@ def test_run_interactive_shell_command_truncates_captured_output() -> None: assert result.return_code == 0 assert len(result.output) == 8 - assert result.output == "xxxxxxx\n" + assert result.output in {"xxxxxxx\n", "xxxxxx\r\n"} def test_update_alt_screen_state_tracks_enter_and_exit_sequences() -> None: diff --git a/tests/unit/fast_agent/ui/test_plugin_command_actions_tui.py b/tests/unit/fast_agent/ui/test_plugin_command_actions_tui.py new file mode 100644 index 000000000..0ac045d41 --- /dev/null +++ b/tests/unit/fast_agent/ui/test_plugin_command_actions_tui.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from prompt_toolkit.keys import Keys + +from fast_agent.agents.agent_types import AgentConfig +from fast_agent.command_actions import PluginCommandActionSpec +from fast_agent.ui.prompt.completer import AgentCompleter +from fast_agent.ui.prompt.keybindings import create_keybindings + +if TYPE_CHECKING: + from fast_agent.core.agent_app import AgentApp + + +def test_plugin_command_keybinding_is_registered() -> None: + kb = create_keybindings( + agent_provider=cast( + "AgentApp", + _Provider( + AgentConfig( + name="dev", + commands={ + "draft-next": PluginCommandActionSpec( + name="draft-next", + description="Draft next", + handler="commands.py:draft_next", + key="c-x d", + ) + }, + ) + ), + ), + agent_name="dev", + ) + + assert (Keys.ControlX, "d") in {binding.keys for binding in kb.bindings} + + +def test_plugin_commands_are_added_to_tui_completion() -> None: + completer = AgentCompleter( + agents=["dev"], + current_agent="dev", + agent_provider=cast( + "AgentApp", + _Provider( + AgentConfig( + name="dev", + commands={ + "review-last": PluginCommandActionSpec( + name="review-last", + description="Review the last assistant response", + input_hint="[agent]", + handler="commands.py:review_last", + key="c-x r", + ) + }, + ) + ), + ), + ) + + assert ( + completer.commands["review-last"] + == "Review the last assistant response [agent] (key: c-x r)" + ) + + +def test_agent_plugin_command_overrides_global_completion() -> None: + completer = AgentCompleter( + agents=["dev"], + current_agent="dev", + agent_provider=cast( + "AgentApp", + _Provider( + AgentConfig( + name="dev", + commands={ + "draft": PluginCommandActionSpec( + name="draft", + description="Agent draft", + handler="agent.py:draft", + ) + }, + ), + plugin_commands={ + "draft": PluginCommandActionSpec( + name="draft", + description="Global draft", + handler="global.py:draft", + ) + }, + ), + ), + ) + + assert completer.commands["draft"] == "Agent draft" + + +class _Agent: + def __init__(self, config: AgentConfig) -> None: + self.config = config + + +class _Provider: + plugin_command_base_path = None + + def __init__( + self, + config: AgentConfig, + plugin_commands: dict[str, PluginCommandActionSpec] | None = None, + ) -> None: + self.agent = _Agent(config) + self.plugin_commands = plugin_commands + + def get_agent(self, name: str) -> Any: + return self.agent diff --git a/tests/unit/fast_agent/ui/test_prompt_input_startup.py b/tests/unit/fast_agent/ui/test_prompt_input_startup.py new file mode 100644 index 000000000..d9c0f561b --- /dev/null +++ b/tests/unit/fast_agent/ui/test_prompt_input_startup.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import pytest + +from fast_agent.ui.prompt import input as prompt_input + +if TYPE_CHECKING: + from fast_agent.core.agent_app import AgentApp + + +@pytest.mark.asyncio +async def test_input_startup_shows_home_summary_without_shell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[object] = [] + provider = object() + + monkeypatch.setattr(prompt_input, "help_message_shown", False) + monkeypatch.setattr(prompt_input, "rich_print", lambda *args, **kwargs: None) + monkeypatch.setattr(prompt_input, "_show_model_shortcut_hints", lambda **kwargs: None) + monkeypatch.setattr( + prompt_input, + "_show_fast_agent_home_summary", + lambda agent_provider: calls.append(agent_provider), + ) + + await prompt_input._show_input_startup( + agent_name="agent", + default="", + show_stop_hint=False, + is_human_input=False, + shell_context=prompt_input.ShellInputContext(enabled=False), + shell_agent=None, + agent_provider=cast("AgentApp", provider), + ) + + assert calls == [provider] diff --git a/tests/unit/fast_agent/ui/test_rich_progress.py b/tests/unit/fast_agent/ui/test_rich_progress.py index 5772558e7..a6946f70c 100644 --- a/tests/unit/fast_agent/ui/test_rich_progress.py +++ b/tests/unit/fast_agent/ui/test_rich_progress.py @@ -15,7 +15,7 @@ def _make_event( - action: ProgressAction = ProgressAction.CHATTING, + action: ProgressAction = ProgressAction.SENDING, agent_name: str | None = "test-agent", target: str = "test-agent", details: str = "", @@ -281,7 +281,7 @@ def test_tool_progress_without_total_does_not_reset(self) -> None: display.start() # First create the task - event = _make_event(action=ProgressAction.CHATTING) + event = _make_event(action=ProgressAction.SENDING) display.update(event) assert "test-agent" in display._taskmap @@ -524,7 +524,7 @@ def test_tool_progress_with_total_sets_completed(self) -> None: display = _make_display() display.start() - event = _make_event(action=ProgressAction.CHATTING) + event = _make_event(action=ProgressAction.SENDING) display.update(event) event = _make_event( @@ -701,7 +701,7 @@ def test_clear_agent_tasks_removes_base_and_correlated_rows(self) -> None: display.update( _make_event( - action=ProgressAction.CHATTING, + action=ProgressAction.SENDING, agent_name="agent-a", target="agent-a", ) @@ -717,7 +717,7 @@ def test_clear_agent_tasks_removes_base_and_correlated_rows(self) -> None: ) display.update( _make_event( - action=ProgressAction.CHATTING, + action=ProgressAction.SENDING, agent_name="agent-b", target="agent-b", ) diff --git a/tests/unit/scripts/test_event_replay.py b/tests/unit/scripts/test_event_replay.py index 18b8979ad..f1d837167 100644 --- a/tests/unit/scripts/test_event_replay.py +++ b/tests/unit/scripts/test_event_replay.py @@ -89,7 +89,7 @@ def test_select_events_applies_window_and_validates() -> None: def test_replay_events_replays_progress_with_scaled_timing() -> None: now = datetime(2026, 2, 20, 12, 0, 0) events = [ - _event(timestamp=now, action=ProgressAction.CHATTING), + _event(timestamp=now, action=ProgressAction.SENDING), _event(timestamp=now + timedelta(seconds=1), action=None), _event(timestamp=now + timedelta(seconds=3), action=ProgressAction.THINKING), ] diff --git a/uv.lock b/uv.lock index beb0df465..9170fa615 100644 --- a/uv.lock +++ b/uv.lock @@ -713,7 +713,7 @@ requires-dist = [{ name = "fast-agent-mcp", editable = "." }] [[package]] name = "fast-agent-mcp" -version = "0.6.26" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -810,9 +810,9 @@ requires-dist = [ { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.35.0" }, { name = "deprecated", specifier = "==1.3.1" }, { name = "email-validator", specifier = "==2.2.0" }, - { name = "fastapi", specifier = "==0.136.0" }, - { name = "fastmcp", specifier = "==3.2.3" }, - { name = "google-genai", specifier = "==1.73.1" }, + { name = "fastapi", specifier = "==0.136.1" }, + { name = "fastmcp", specifier = "==3.2.4" }, + { name = "google-genai", specifier = "==1.74.0" }, { name = "huggingface-hub", specifier = "==1.12.2" }, { name = "jsonschema", specifier = "==4.25.1" }, { name = "keyring", specifier = "==25.7.0" }, @@ -824,7 +824,7 @@ requires-dist = [ { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'privacy-gpu'", specifier = ">=9" }, { name = "onnxruntime", marker = "extra == 'privacy'", specifier = ">=1.25" }, { name = "onnxruntime-gpu", marker = "extra == 'privacy-gpu'", specifier = ">=1.25" }, - { name = "openai", extras = ["aiohttp"], specifier = "==2.32.0" }, + { name = "openai", extras = ["aiohttp"], specifier = "==2.33.0" }, { name = "opentelemetry-distro", specifier = "==0.60b1" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.39.1" }, { name = "opentelemetry-instrumentation-anthropic", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.52.1" }, @@ -832,7 +832,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-mcp", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.52.1" }, { name = "opentelemetry-instrumentation-openai", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.52.1" }, { name = "prompt-toolkit", specifier = "==3.0.52" }, - { name = "pydantic", specifier = "==2.13.1" }, + { name = "pydantic", specifier = "==2.13.3" }, { name = "pydantic-settings", specifier = "==2.13.0" }, { name = "pyperclip", specifier = "==1.9.0" }, { name = "python-frontmatter", specifier = "==1.1.0" }, @@ -869,7 +869,7 @@ dev = [ [[package]] name = "fastapi" -version = "0.136.0" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -878,19 +878,20 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] name = "fastmcp" -version = "3.2.3" +version = "3.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, + { name = "griffelib" }, { name = "httpx" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -910,9 +911,9 @@ dependencies = [ { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/42/7eed0a38e3b7a386805fecacf8a5a9353a2b3040395ef9e30e585d8549ac/fastmcp-3.2.3.tar.gz", hash = "sha256:4f02ae8b00227285a0cf6544dea1db29b022c8cdd8d3dfdec7118540210ae60a", size = 26328743, upload-time = "2026-04-09T22:05:03.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/48/84b6dcba793178a44b9d99b4def6cd62f870dcfc5bb7b9153ac390135812/fastmcp-3.2.3-py3-none-any.whl", hash = "sha256:cc50af6eed1f62ed8b6ebf4987286d8d1d006f08d5bec739d5c7fb76160e0911", size = 707260, upload-time = "2026-04-09T22:05:01.225Z" }, + { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" }, ] [[package]] @@ -1050,7 +1051,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.73.1" +version = "1.74.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1064,9 +1065,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998, upload-time = "2026-04-14T21:06:19.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c8/4a8f1de0a3268d526a345b8c74456b3e1e6ffd200982626326cf7ca83e5b/google_genai-1.74.0.tar.gz", hash = "sha256:c4c473cebdeb6e5adbb0639326de66a3a85a2209e0d32de7d66bf05c698abae8", size = 536772, upload-time = "2026-04-29T22:16:35.881Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595, upload-time = "2026-04-14T21:06:17.464Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2b/539c328b66f7bfef2df869371a1789361228e5a7694ba02a642608367b46/google_genai-1.74.0-py3-none-any.whl", hash = "sha256:87d0b311c67d4b2a0ca741e9fc6891330c29defae81d46d8db41079aa1a3d80a", size = 790433, upload-time = "2026-04-29T22:16:33.979Z" }, ] [[package]] @@ -1081,6 +1082,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1866,7 +1876,7 @@ wheels = [ [[package]] name = "openai" -version = "2.32.0" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1878,9 +1888,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, + { url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" }, ] [package.optional-dependencies] @@ -2379,7 +2389,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.1" +version = "2.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2387,9 +2397,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/6b/1353beb3d1cd5cf61cdec5b6f87a9872399de3bc5cae0b7ce07ff4de2ab0/pydantic-2.13.1.tar.gz", hash = "sha256:a0f829b279ddd1e39291133fe2539d2aa46cc6b150c1706a270ff0879e3774d2", size = 843746, upload-time = "2026-04-15T14:57:19.398Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/5a/2225f4c176dbfed0d809e848b50ef08f70e61daa667b7fa14b0d311ae44d/pydantic-2.13.1-py3-none-any.whl", hash = "sha256:9557ecc2806faaf6037f85b1fbd963d01e30511c48085f0d573650fdeaad378a", size = 471917, upload-time = "2026-04-15T14:57:17.277Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] [package.optional-dependencies] @@ -2399,58 +2409,58 @@ email = [ [[package]] name = "pydantic-core" -version = "2.46.1" +version = "2.46.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/93/f97a86a7eb28faa1d038af2fd5d6166418b4433659108a4c311b57128b2d/pydantic_core-2.46.1.tar.gz", hash = "sha256:d408153772d9f298098fb5d620f045bdf0f017af0d5cb6e309ef8c205540caa4", size = 471230, upload-time = "2026-04-15T14:49:34.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/d2/bda39bad2f426cb5078e6ad28076614d3926704196efe0d7a2a19a99025d/pydantic_core-2.46.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:cdc8a5762a9c4b9d86e204d555444e3227507c92daba06259ee66595834de47a", size = 2119092, upload-time = "2026-04-15T14:49:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f3/69631e64d69cb3481494b2bddefe0ddd07771209f74e9106d066f9138c2a/pydantic_core-2.46.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba381dfe9c85692c566ecb60fa5a77a697a2a8eebe274ec5e4d6ec15fafad799", size = 1951400, upload-time = "2026-04-15T14:51:06.588Z" }, - { url = "https://files.pythonhosted.org/packages/53/1c/21cb3db6ae997df31be8e91f213081f72ffa641cb45c89b8a1986832b1f9/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1593d8de98207466dc070118322fef68307a0cc6a5625e7b386f6fdae57f9ab6", size = 1976864, upload-time = "2026-04-15T14:50:54.804Z" }, - { url = "https://files.pythonhosted.org/packages/91/9c/05c819f734318ce5a6ca24da300d93696c105af4adb90494ee571303afd8/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8262c74a1af5b0fdf795f5537f7145785a63f9fbf9e15405f547440c30017ed8", size = 2066669, upload-time = "2026-04-15T14:51:42.346Z" }, - { url = "https://files.pythonhosted.org/packages/cb/23/fadddf1c7f2f517f58731aea9b35c914e6005250f08dac9b8e53904cdbaa/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b88949a24182e83fbbb3f7ca9b7858d0d37b735700ea91081434b7d37b3b444", size = 2238737, upload-time = "2026-04-15T14:50:45.558Z" }, - { url = "https://files.pythonhosted.org/packages/23/07/0cd4f95cb0359c8b1ec71e89c3777e7932c8dfeb9cd54740289f310aaead/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8f3708cd55537aeaf3fd0ea55df0d68d0da51dcb07cbc8508745b34acc4c6e0", size = 2316258, upload-time = "2026-04-15T14:51:08.471Z" }, - { url = "https://files.pythonhosted.org/packages/0c/40/6fc24c3766a19c222a0d60d652b78f0283339d4cd4c173fab06b7ee76571/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f79292435fff1d4f0c18d9cfaf214025cc88e4f5104bfaed53f173621da1c743", size = 2097474, upload-time = "2026-04-15T14:49:56.543Z" }, - { url = "https://files.pythonhosted.org/packages/4b/af/f39795d1ce549e35d0841382b9c616ae211caffb88863147369a8d74fba9/pydantic_core-2.46.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a2e607aeb59cf4575bb364470288db3b9a1f0e7415d053a322e3e154c1a0802e", size = 2168383, upload-time = "2026-04-15T14:51:29.269Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/0d563f74582795779df6cc270c3fc220f49f4daf7860d74a5a6cda8491ff/pydantic_core-2.46.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec5ca190b75878a9f6ae1fc8f5eb678497934475aef3d93204c9fa01e97370b6", size = 2186182, upload-time = "2026-04-15T14:50:19.097Z" }, - { url = "https://files.pythonhosted.org/packages/5c/07/1c10d5ce312fc4cf86d1e50bdcdbb8ef248409597b099cab1b4bb3a093f7/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1f80535259dcdd517d7b8ca588d5ca24b4f337228e583bebedf7a3adcdf5f721", size = 2187859, upload-time = "2026-04-15T14:49:22.974Z" }, - { url = "https://files.pythonhosted.org/packages/92/01/e1f62d4cb39f0913dbf5c95b9b119ef30ddba9493dff8c2b012f0cdd67dc/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:24820b3c82c43df61eca30147e42853e6c127d8b868afdc0c162df829e011eb4", size = 2338372, upload-time = "2026-04-15T14:49:53.316Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/218dfeea6127fb1781a6ceca241ec6edf00e8a8933ff331af2215975a534/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f12794b1dd8ac9fb66619e0b3a0427189f5d5638e55a3de1385121a9b7bf9b39", size = 2384039, upload-time = "2026-04-15T14:53:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1e/011e763cd059238249fbd5780e0f8d0b04b47f86c8925e22784f3e5fc977/pydantic_core-2.46.1-cp313-cp313-win32.whl", hash = "sha256:9bc09aed935cdf50f09e908923f9efbcca54e9244bd14a5a0e2a6c8d2c21b4e9", size = 1977943, upload-time = "2026-04-15T14:52:17.969Z" }, - { url = "https://files.pythonhosted.org/packages/8c/06/b559a490d3ed106e9b1777b8d5c8112dd8d31716243cd662616f66c1f8ea/pydantic_core-2.46.1-cp313-cp313-win_amd64.whl", hash = "sha256:fac2d6c8615b8b42bee14677861ba09d56ee076ba4a65cfb9c3c3d0cc89042f2", size = 2068729, upload-time = "2026-04-15T14:53:07.288Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/32a198946e2e19508532aa9da02a61419eb15bd2d96bab57f810f2713e31/pydantic_core-2.46.1-cp313-cp313-win_arm64.whl", hash = "sha256:f978329f12ace9f3cb814a5e44d98bbeced2e36f633132bafa06d2d71332e33e", size = 2029550, upload-time = "2026-04-15T14:52:22.707Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2b/6793fe89ab66cb2d3d6e5768044eab80bba1d0fae8fd904d0a1574712e17/pydantic_core-2.46.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9917cb61effac7ec0f448ef491ec7584526d2193be84ff981e85cbf18b68c42a", size = 2118110, upload-time = "2026-04-15T14:50:52.947Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/e9a905ddfcc2fd7bd862b340c02be6ab1f827922822d425513635d0ac774/pydantic_core-2.46.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e749679ca9f8a9d0bff95fb7f6b57bb53f2207fa42ffcc1ec86de7e0029ab89", size = 1948645, upload-time = "2026-04-15T14:51:55.577Z" }, - { url = "https://files.pythonhosted.org/packages/15/23/26e67f86ed62ac9d6f7f3091ee5220bf14b5ac36fb811851d601365ef896/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2ecacee70941e233a2dad23f7796a06f86cc10cc2fbd1c97c7dd5b5a79ffa4f", size = 1977576, upload-time = "2026-04-15T14:49:37.58Z" }, - { url = "https://files.pythonhosted.org/packages/b8/78/813c13c0de323d4de54ee2e6fdd69a0271c09ac8dd65a8a000931aa487a5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:647d0a2475b8ed471962eed92fa69145b864942f9c6daa10f95ac70676637ae7", size = 2060358, upload-time = "2026-04-15T14:51:40.087Z" }, - { url = "https://files.pythonhosted.org/packages/09/5e/4caf2a15149271fbd2b4d968899a450853c800b85152abcf54b11531417f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac9cde61965b0697fce6e6cc372df9e1ad93734828aac36e9c1c42a22ad02897", size = 2235980, upload-time = "2026-04-15T14:50:34.535Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c1/a2cdabb5da6f5cb63a3558bcafffc20f790fa14ccffbefbfb1370fadc93f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a2eb0864085f8b641fb3f54a2fb35c58aff24b175b80bc8a945050fcde03204", size = 2316800, upload-time = "2026-04-15T14:52:46.999Z" }, - { url = "https://files.pythonhosted.org/packages/76/fd/19d711e4e9331f9d77f222bffc202bf30ea0d74f6419046376bb82f244c8/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b83ce9fede4bc4fb649281d9857f06d30198b8f70168f18b987518d713111572", size = 2101762, upload-time = "2026-04-15T14:49:24.278Z" }, - { url = "https://files.pythonhosted.org/packages/dc/64/ce95625448e1a4e219390a2923fd594f3fa368599c6b42ac71a5df7238c9/pydantic_core-2.46.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:cb33192753c60f269d2f4a1db8253c95b0df6e04f2989631a8cc1b0f4f6e2e92", size = 2167737, upload-time = "2026-04-15T14:50:41.637Z" }, - { url = "https://files.pythonhosted.org/packages/ad/31/413572d03ca3e73b408f00f54418b91a8be6401451bc791eaeff210328e5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96611d51f953f87e1ae97637c01ee596a08b7f494ea00a5afb67ea6547b9f53b", size = 2185658, upload-time = "2026-04-15T14:51:46.799Z" }, - { url = "https://files.pythonhosted.org/packages/36/09/e4f581353bdf3f0c7de8a8b27afd14fc761da29d78146376315a6fedc487/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9b176fa55f9107db5e6c86099aa5bfd934f1d3ba6a8b43f714ddeebaed3f42b7", size = 2184154, upload-time = "2026-04-15T14:52:49.629Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a4/d0d52849933f5a4bf1ad9d8da612792f96469b37e286a269e3ee9c60bbb1/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:79a59f63a4ce4f3330e27e6f3ce281dd1099453b637350e97d7cf24c207cd120", size = 2332379, upload-time = "2026-04-15T14:49:55.009Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/25bfb08fdbef419f73290e573899ce938a327628c34e8f3a4bafeea30126/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:f200fce071808a385a314b7343f5e3688d7c45746be3d64dc71ee2d3e2a13268", size = 2377964, upload-time = "2026-04-15T14:51:59.649Z" }, - { url = "https://files.pythonhosted.org/packages/15/36/b777766ff83fef1cf97473d64764cd44f38e0d8c269ed06faace9ae17666/pydantic_core-2.46.1-cp314-cp314-win32.whl", hash = "sha256:3a07eccc0559fb9acc26d55b16bf8ebecd7f237c74a9e2c5741367db4e6d8aff", size = 1976450, upload-time = "2026-04-15T14:51:57.665Z" }, - { url = "https://files.pythonhosted.org/packages/7b/4b/4cd19d2437acfc18ca166db5a2067040334991eb862c4ecf2db098c91fbf/pydantic_core-2.46.1-cp314-cp314-win_amd64.whl", hash = "sha256:1706d270309ac7d071ffe393988c471363705feb3d009186e55d17786ada9622", size = 2067750, upload-time = "2026-04-15T14:49:38.941Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a0/490751c0ef8f5b27aae81731859aed1508e72c1a9b5774c6034269db773b/pydantic_core-2.46.1-cp314-cp314-win_arm64.whl", hash = "sha256:22d4e7457ade8af06528012f382bc994a97cc2ce6e119305a70b3deff1e409d6", size = 2021109, upload-time = "2026-04-15T14:50:27.728Z" }, - { url = "https://files.pythonhosted.org/packages/36/3a/2a018968245fffd25d5f1972714121ad309ff2de19d80019ad93494844f9/pydantic_core-2.46.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:607ff9db0b7e2012e7eef78465e69f9a0d7d1c3e7c6a84cf0c4011db0fcc3feb", size = 2111548, upload-time = "2026-04-15T14:52:08.273Z" }, - { url = "https://files.pythonhosted.org/packages/77/5b/4103b6192213217e874e764e5467d2ff10d8873c1147d01fa432ac281880/pydantic_core-2.46.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cda3eacaea13bd02a1bea7e457cc9fc30b91c5a91245cef9b215140f80dd78c", size = 1926745, upload-time = "2026-04-15T14:50:03.045Z" }, - { url = "https://files.pythonhosted.org/packages/c3/70/602a667cf4be4bec6c3334512b12ae4ea79ce9bfe41dc51be1fd34434453/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9493279cdc7997fe19e5ed9b41f30cbc3806bd4722adb402fedb6f6d41bd72a", size = 1965922, upload-time = "2026-04-15T14:51:12.555Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/06a89ce5323e755b7d2812189f9706b87aaebe49b34d247b380502f7992c/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3644e5e10059999202355b6c6616e624909e23773717d8f76deb8a6e2a72328c", size = 2043221, upload-time = "2026-04-15T14:51:18.995Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6e/b1d9ad907d9d76964903903349fd2e33c87db4b993cc44713edcad0fc488/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ad6c9de57683e26c92730991960c0c3571b8053263b042de2d3e105930b2767", size = 2243655, upload-time = "2026-04-15T14:50:10.718Z" }, - { url = "https://files.pythonhosted.org/packages/ef/73/787abfaad51174641abb04c8aa125322279b40ad7ce23c495f5a69f76554/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:557ebaa27c7617e7088002318c679a8ce685fa048523417cd1ca52b7f516d955", size = 2295976, upload-time = "2026-04-15T14:53:09.694Z" }, - { url = "https://files.pythonhosted.org/packages/56/0b/b7c5a631b6d5153d4a1ea4923b139aea256dc3bd99c8e6c7b312c7733146/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cd37e39b22b796ba0298fe81e9421dd7b65f97acfbb0fb19b33ffdda7b9a7b4", size = 2103439, upload-time = "2026-04-15T14:50:08.32Z" }, - { url = "https://files.pythonhosted.org/packages/2a/3f/952ee470df69e5674cdec1cbde22331adf643b5cc2ff79f4292d80146ee4/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:6689443b59714992e67d62505cdd2f952d6cf1c14cc9fd9aeec6719befc6f23b", size = 2132871, upload-time = "2026-04-15T14:50:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/e3/8b/1dea3b1e683c60c77a60f710215f90f486755962aa8939dbcb7c0f975ac3/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f32c41ca1e3456b5dd691827b7c1433c12d5f0058cc186afbb3615bc07d97b8", size = 2168658, upload-time = "2026-04-15T14:52:24.897Z" }, - { url = "https://files.pythonhosted.org/packages/67/97/32ae283810910d274d5ba9f48f856f5f2f612410b78b249f302d297816f5/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:88cd1355578852db83954dc36e4f58f299646916da976147c20cf6892ba5dc43", size = 2171184, upload-time = "2026-04-15T14:52:34.854Z" }, - { url = "https://files.pythonhosted.org/packages/a2/57/c9a855527fe56c2072070640221f53095b0b19eaf651f3c77643c9cabbe3/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a170fefdb068279a473cc9d34848b85e61d68bfcc2668415b172c5dfc6f213bf", size = 2316573, upload-time = "2026-04-15T14:52:12.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/14c39ffc7399819c5448007c7bcb4e6da5669850cfb7dcbb727594290b48/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:556a63ff1006934dba4eed7ea31b58274c227e29298ec398e4275eda4b905e95", size = 2378340, upload-time = "2026-04-15T14:51:02.619Z" }, - { url = "https://files.pythonhosted.org/packages/01/55/a37461fbb29c053ea4e62cfc5c2d56425cb5efbef8316e63f6d84ae45718/pydantic_core-2.46.1-cp314-cp314t-win32.whl", hash = "sha256:3b146d8336a995f7d7da6d36e4a779b7e7dff2719ac00a1eb8bd3ded00bec87b", size = 1960843, upload-time = "2026-04-15T14:52:06.103Z" }, - { url = "https://files.pythonhosted.org/packages/22/d7/97e1221197d17a27f768363f87ec061519eeeed15bbd315d2e9d1429ff03/pydantic_core-2.46.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f1bc856c958e6fe9ec071e210afe6feb695f2e2e81fd8d2b102f558d364c4c17", size = 2048696, upload-time = "2026-04-15T14:52:52.154Z" }, - { url = "https://files.pythonhosted.org/packages/19/d5/4eac95255c7d35094b46a32ec1e4d80eac94729c694726ee1d69948bd5f0/pydantic_core-2.46.1-cp314-cp314t-win_arm64.whl", hash = "sha256:21a5bfd8a1aa4de60494cdf66b0c912b1495f26a8899896040021fbd6038d989", size = 2022343, upload-time = "2026-04-15T14:49:49.036Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, ] [[package]]