From 63a52b3a4a42c0a247fcbea122ac56bafdcc0c3d Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:33:45 +0200 Subject: [PATCH 1/4] Expose slash commands through skills Delegate scoped command discovery to the Commands plugin and let skills_tool list or read effective slash-command definitions without invoking them. --- helpers/skills.py | 48 +++++++++++++++++++++++ helpers/skills.py.dox.md | 4 ++ prompts/agent.system.tool.skills.md | 2 +- tests/test_skills_runtime.py | 57 +++++++++++++++++++++++++++ tests/test_tool_action_contracts.py | 36 +++++++++++++++++ tools/skills_tool.py | 60 +++++++++++++++++++++-------- tools/skills_tool.py.dox.md | 3 +- 7 files changed, 193 insertions(+), 17 deletions(-) diff --git a/helpers/skills.py b/helpers/skills.py index a2a4e29cd..87ecc9afe 100644 --- a/helpers/skills.py +++ b/helpers/skills.py @@ -398,6 +398,54 @@ def list_skills( return _filter_hidden_skills(agent, result) +def list_slash_commands(agent: Agent | None = None) -> list[dict[str, Any]]: + """List effective, picker-visible slash commands for the agent's project.""" + # Local import avoids the commands helper's import of split_frontmatter above. + from plugins._commands.helpers import commands as commands_helper + + commands, _ = commands_helper.list_effective_commands( + _get_agent_project_name(agent) + ) + return [ + command + for command in commands + if not bool((command.get("frontmatter_extra") or {}).get("webui_hidden")) + ] + + +def find_slash_command( + command_name: str, + agent: Agent | None = None, +) -> dict[str, Any] | None: + """Find one effective slash command by its canonical ``/name``.""" + target = str(command_name or "").strip().lstrip("/").lower() + if not target: + return None + return next( + (command for command in list_slash_commands(agent) if command["name"] == target), + None, + ) + + +def format_slash_command(command: dict[str, Any]) -> str: + """Render a slash command definition for a skills-tool result.""" + lines = [f"Slash command: /{command['name']}"] + if description := str(command.get("description") or "").strip(): + lines.append(f"Description: {description}") + if argument_hint := str(command.get("argument_hint") or "").strip(): + lines.append(f"Arguments: {argument_hint}") + lines.append(f"Type: {command.get('command_type') or 'text'}") + if scope := str(command.get("scope_label") or "").strip(): + lines.append(f"Scope: {scope}") + + body = str(command.get("body") or "").strip() + if body: + if len(body) > 24000: + body = body[:24000].rstrip() + "\n\n[truncated]" + lines.extend(["", "Definition:", body]) + return "\n".join(lines) + + def delete_skill( skill_path: str, ) -> None: diff --git a/helpers/skills.py.dox.md b/helpers/skills.py.dox.md index 363edfca4..2d2a522e8 100644 --- a/helpers/skills.py.dox.md +++ b/helpers/skills.py.dox.md @@ -27,6 +27,9 @@ - `parse_frontmatter(frontmatter_text: str) -> Tuple[Dict[str, Any], List[str]]`: Parse YAML frontmatter with PyYAML when available, - `skill_from_markdown(skill_md_path: Path, include_content: bool=..., validate: bool=...) -> Optional[Skill]` - `list_skills(agent: Agent | None=..., include_content: bool=..., include_hidden: bool=...) -> List[Skill]`: List skills, optionally filtered by agent scope. +- `list_slash_commands(agent: Agent | None=...) -> list[dict[str, Any]]`: List picker-visible effective slash commands for the agent project. +- `find_slash_command(command_name: str, agent: Agent | None=...) -> dict[str, Any] | None` +- `format_slash_command(command: dict[str, Any]) -> str` - `delete_skill(skill_path: str) -> None`: Delete a skill directory. - `find_skill(skill_name: str, agent: Agent | None=..., include_content: bool=..., include_hidden: bool=..., validate: bool=...) -> Optional[Skill]` - `load_skill_for_agent(skill_name: str, agent: Agent | None=...) -> str`: Load skill and format it as a complete string for agent context. @@ -60,6 +63,7 @@ - `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol. - `search_skills()` normalizes query words, scores normal terms against skill names, and scores only long terms against tags/triggers; descriptions match only full query phrases so generic prose does not produce irrelevant suggestions. - `find_skill(validate=False)` lets validation tooling resolve a skill with incomplete metadata while preserving runtime validation by default. +- Slash command discovery is delegated to the built-in `_commands` helper through a local import, preserving its project/global/bundled/plugin precedence and avoiding its `split_frontmatter` import cycle. Picker-hidden commands remain hidden from Skills; reading a command returns its definition without executing it. - Invalid `SKILL.md` frontmatter emits a once-per-path scan warning with the skipped skill path/name and a line number when the parser can identify one directly. - Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling. - Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`. diff --git a/prompts/agent.system.tool.skills.md b/prompts/agent.system.tool.skills.md index c9e5ad4f7..01861f3f4 100644 --- a/prompts/agent.system.tool.skills.md +++ b/prompts/agent.system.tool.skills.md @@ -5,7 +5,7 @@ common args: action skill_name query file_path workflow: - action `search`: find candidate skills by keywords or trigger phrases from the current task - action `list`: discover available skills -- action `load`: append one skill's full instructions to chat history by `skill_name` +- action `load`: append one skill's full instructions to chat history by `skill_name`; use `skill_name=/command` to read an effective slash-command definition without invoking it - action `read_file`: open one file inside a loaded skill directory if the user says "find/search a skill", call `search` before `load` even when the likely skill name seems obvious `read_file` requires both `skill_name` and `file_path`; load the skill first, then read `SKILL.md` or the named relative file diff --git a/tests/test_skills_runtime.py b/tests/test_skills_runtime.py index 8228e4c36..9ae3984ee 100644 --- a/tests/test_skills_runtime.py +++ b/tests/test_skills_runtime.py @@ -129,6 +129,63 @@ def test_active_skills_cap_is_twenty(): assert runtime.get_max_active_skills() == 20 +def test_slash_commands_use_agent_scope_and_hide_picker_hidden(monkeypatch): + plugins_pkg = types.ModuleType("plugins") + plugins_pkg.__path__ = [] + commands_plugin_pkg = types.ModuleType("plugins._commands") + commands_plugin_pkg.__path__ = [] + commands_helpers_pkg = types.ModuleType("plugins._commands.helpers") + commands_helpers_pkg.__path__ = [] + commands = types.ModuleType("plugins._commands.helpers.commands") + calls = [] + commands.list_effective_commands = lambda project_name: ( + [ + { + "name": "visible", + "description": "Visible command.", + "argument_hint": "", + "command_type": "text", + "scope_label": "Project", + "body": "Template {text}", + "frontmatter_extra": {}, + }, + { + "name": "hidden", + "frontmatter_extra": {"webui_hidden": True}, + }, + ], + {"project_name": project_name}, + ) + commands_helpers_pkg.commands = commands + for name, module in ( + ("plugins", plugins_pkg), + ("plugins._commands", commands_plugin_pkg), + ("plugins._commands.helpers", commands_helpers_pkg), + ("plugins._commands.helpers.commands", commands), + ): + monkeypatch.setitem(sys.modules, name, module) + monkeypatch.setattr( + runtime, + "_get_agent_project_name", + lambda _agent: calls.append("project") or "project", + ) + + command = runtime.find_slash_command("/visible", DummyAgent()) + + assert calls == ["project"] + assert command["name"] == "visible" + assert runtime.find_slash_command("/hidden", DummyAgent()) is None + assert runtime.format_slash_command(command) == ( + "Slash command: /visible\n" + "Description: Visible command.\n" + "Arguments: \n" + "Type: text\n" + "Scope: Project\n\n" + "Definition:\n" + "Template {text}" + ) + + def test_skills_config_can_raise_active_cap_above_default(): config = runtime.normalize_skills_config( { diff --git a/tests/test_tool_action_contracts.py b/tests/test_tool_action_contracts.py index 45defc0fb..3203c4d45 100644 --- a/tests/test_tool_action_contracts.py +++ b/tests/test_tool_action_contracts.py @@ -130,6 +130,20 @@ def _load_skills_tool(monkeypatch, skill_root: Path): tags=[], ) skills_stub.list_skills = lambda *args, **kwargs: [fake_skill] + fake_command = { + "name": "summarize", + "description": "Summarize the current work.", + "argument_hint": "[focus]", + } + skills_stub.list_slash_commands = lambda *args, **kwargs: [fake_command] + skills_stub.find_slash_command = ( + lambda command_name, *args, **kwargs: ( + fake_command if command_name == "/summarize" else None + ) + ) + skills_stub.format_slash_command = ( + lambda command: f"Slash command: /{command['name']}\nDefinition: prompt" + ) skills_stub.search_skills = lambda *args, **kwargs: [fake_skill] skills_stub.find_skill = lambda *args, **kwargs: fake_skill skills_stub.load_skill_for_agent = ( @@ -336,6 +350,28 @@ def test_skills_tool_defaults_missing_action_to_list(monkeypatch, tmp_path: Path assert "Available skills" in response.message assert "browser-form-workflows" in response.message + assert "Available slash commands" in response.message + assert "/summarize [focus]" in response.message + + +def test_skills_tool_load_reads_slash_command_without_loading_a_skill( + monkeypatch, tmp_path: Path +): + module = _load_skills_tool(monkeypatch, tmp_path) + agent = _FakeAgent() + tool = module.SkillsTool( + agent, + "skills_tool", + None, + {"action": "load", "skill_name": "/summarize"}, + "", + None, + ) + + response = asyncio.run(tool.execute(**tool.args)) + + assert response.message == "Slash command: /summarize\nDefinition: prompt" + assert agent.context.get_data("loaded_skills") is None def test_skills_tool_load_appends_skill_instructions_as_tool_result( diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 99628b7a0..13cfc4df7 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -18,7 +18,7 @@ class SkillsTool(Tool): Actions (tool_args.action): - list - search (query) - - load (skill_name) + - load (skill_name, or /command) - read_file (skill_name, file_path) Script execution is handled by code_execution_tool directly. @@ -144,23 +144,38 @@ class SkillsTool(Tool): agent=self.agent, include_content=False, ) - if not skills: - return "No skills found." - - # Stable output: sort by name - skills_sorted = sorted(skills, key=lambda s: s.name.lower()) + commands = skills_helper.list_slash_commands(agent=self.agent) + if not skills and not commands: + return "No skills or slash commands found." lines: List[str] = [] - lines.append(f"Available skills ({len(skills_sorted)}):") - for s in skills_sorted: - tags = f" tags={','.join(s.tags)}" if s.tags else "" - ver = f" v{s.version}" if s.version else "" - desc = (s.description or "").strip() - if len(desc) > 200: - desc = desc[:200].rstrip() + "…" - lines.append(f"- {s.name}{ver}{tags}: {desc}") + if skills: + # Stable output: sort by name + skills_sorted = sorted(skills, key=lambda s: s.name.lower()) + lines.append(f"Available skills ({len(skills_sorted)}):") + for s in skills_sorted: + tags = f" tags={','.join(s.tags)}" if s.tags else "" + ver = f" v{s.version}" if s.version else "" + desc = (s.description or "").strip() + if len(desc) > 200: + desc = desc[:200].rstrip() + "..." + lines.append(f"- {s.name}{ver}{tags}: {desc}") + + if commands: + if lines: + lines.append("") + lines.append(f"Available slash commands ({len(commands)}):") + for command in commands: + arguments = str(command.get("argument_hint") or "").strip() + desc = str(command.get("description") or "").strip() + if len(desc) > 200: + desc = desc[:200].rstrip() + "..." + suffix = f" {arguments}" if arguments else "" + lines.append(f"- /{command['name']}{suffix}: {desc}") lines.append("") - lines.append("Tip: use skills_tool action=search or action=load for details.") + lines.append( + "Tip: use skills_tool action=search for skills or action=load skill_name=/name to read a slash command." + ) return "\n".join(lines) def _search(self, query: str) -> str: @@ -197,6 +212,21 @@ class SkillsTool(Tool): break_loop=False, ) + if skill_name.startswith("/"): + command = skills_helper.find_slash_command(skill_name, agent=self.agent) + if not command: + return Response( + message=( + f"Error: slash command not found: {skill_name!r}. " + "Try skills_tool action=list." + ), + break_loop=False, + ) + return Response( + message=skills_helper.format_slash_command(command), + break_loop=False, + ) + # Verify skill exists skill = skills_helper.find_skill( skill_name, diff --git a/tools/skills_tool.py.dox.md b/tools/skills_tool.py.dox.md index 1c3e70e07..dc0473be8 100644 --- a/tools/skills_tool.py.dox.md +++ b/tools/skills_tool.py.dox.md @@ -3,7 +3,7 @@ ## Purpose - Own the `skills_tool.py` agent tool. -- This module searches, loads, and lists Agent Zero skills for the agent. +- This module searches, loads, and lists Agent Zero skills and exposes effective slash-command definitions for the agent. - Keep this file-level DOX profile synchronized with `skills_tool.py` because this directory is intentionally flat. ## Ownership @@ -30,6 +30,7 @@ - Loaded skill IDs are stored in chat-wide context data. - Duplicate loads omit the full body when the same skill name remains visible in model history. - Missing or empty `action` defaults to `list`, and legacy `method` is accepted as a deprecated alias when `action` is absent. +- `list` includes picker-visible slash commands for the active project. `load` with `skill_name=/name` reads the effective command definition without executing it or adding it to the loaded-skill ledger. - Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence, chat history persistence. - Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`. From 4b0feac1f45702fc15f5b0d42838ce75edbfef4e Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:52:11 +0200 Subject: [PATCH 2/4] Fix malformed native Responses tool output Keep Agent Zero wrapper examples out of native function descriptions and expose the response text schema.\n\nRoute concatenated tool envelopes through repair before the plain response hook can render them as final text. --- .../end/_10_log_plain_responses.py | 5 ++++- helpers/extract_tools.py | 9 +++++++++ helpers/extract_tools.py.dox.md | 2 +- helpers/responses_tools.py | 2 +- helpers/responses_tools.py.dox.md | 1 + prompts/agent.system.tool.response.md | 4 ++-- tests/test_responses_tools.py | 13 +++++++++++++ tests/test_tool_request_normalization.py | 8 ++++++++ 8 files changed, 39 insertions(+), 5 deletions(-) diff --git a/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py b/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py index e11e660f6..28c80e77c 100644 --- a/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py +++ b/extensions/python/_functions/agent/Agent/hist_add_ai_response/end/_10_log_plain_responses.py @@ -23,7 +23,10 @@ class LogPlainResponses(Extension): message = call_args[1] if not isinstance(message, str) or not message: return - if extract_tools.extract_tool_request(message) is not None: + if ( + extract_tools.extract_tool_request(message) is not None + or extract_tools.is_misformatted_tool_request(message) + ): return params = getattr(getattr(self.agent, "loop_data", None), "params_temporary", None) diff --git a/helpers/extract_tools.py b/helpers/extract_tools.py index 55a1a67e0..e1996da34 100644 --- a/helpers/extract_tools.py +++ b/helpers/extract_tools.py @@ -38,6 +38,15 @@ def is_misformatted_tool_request(content: str) -> bool: return False content = content.strip() + roots = extract_json_root_strings(content) + if ( + len(roots) > 1 + and content.startswith("{") + and content.endswith("}") + and any(extract_tool_request(root) is not None for root in roots) + ): + return True + for fenced_content in re.findall( r"```(?:json)?\s*(.*?)```", content, flags=re.IGNORECASE | re.DOTALL ): diff --git a/helpers/extract_tools.py.dox.md b/helpers/extract_tools.py.dox.md index adfc19881..d2892cb3b 100644 --- a/helpers/extract_tools.py.dox.md +++ b/helpers/extract_tools.py.dox.md @@ -29,7 +29,7 @@ - Dirty parsing scans complete JSON object roots in prose and prefers the first object that normalizes as a valid tool request for permissive repair and legacy callers. Normalization accepts canonical `tool_name`/`tool_args`, legacy `tool`/`args`, native `type="function"` `name`/`parameters`, and a single-item `actions` wrapper; malformed or multi-action wrappers are rejected. - `extract_tool_request` is the execution boundary: it accepts a request only when the complete trimmed content is one valid tool object. Plain text, ordinary JSON, and tool-shaped JSON embedded in prose remain final text. -- `is_misformatted_tool_request` identifies either a tool request wrapped in a JSON code fence or a complete Agent Zero envelope that starts with `thoughts` and whose dirty parser has absorbed `headline`, `tool_name`, and `tool_args` into that list. It routes that output to the existing repair prompt without executing it. +- `is_misformatted_tool_request` identifies a tool request wrapped in a JSON code fence, concatenated complete roots containing tool intent, or a complete Agent Zero envelope that starts with `thoughts` and whose dirty parser has absorbed `headline`, `tool_name`, and `tool_args` into that list. It routes that output to the existing repair prompt without executing it. - Streaming tool snapshots use `extract_tool_request`; the permissive root helpers remain available for repair and legacy callers, not tool execution. - Root extraction ignores objects nested inside an open parent object, so streamed wrapper tools such as `parallel` cannot stop early on the first nested `tool_calls` item. - Imported dependency areas include: `dirty_json`, `helpers.modules`, `re`, `regex`, `typing`. diff --git a/helpers/responses_tools.py b/helpers/responses_tools.py index 453e7d731..3b7ff725f 100644 --- a/helpers/responses_tools.py +++ b/helpers/responses_tools.py @@ -157,7 +157,7 @@ def _description_from_prompt(prompt: str, *, fallback: str) -> str: in_fence = False for raw_line in (prompt or "").splitlines(): line = raw_line.strip() - if line.startswith("```"): + if line.startswith(("```", "~~~")): in_fence = not in_fence continue if in_fence or not line: diff --git a/helpers/responses_tools.py.dox.md b/helpers/responses_tools.py.dox.md index e7ec0e760..6fd2727d4 100644 --- a/helpers/responses_tools.py.dox.md +++ b/helpers/responses_tools.py.dox.md @@ -15,6 +15,7 @@ - Build local function tools from enabled `agent.system.tool.*.md` prompt files. - Local prompt-derived function names prefer explicit `"tool_name"` examples, then the first prompt heading, and only fall back to the prompt filename when the prompt declares no callable name. - Function parameter schemas are object schemas with an explicit `properties` object so OpenAI-compatible servers that validate chat-style tool payloads accept permissive tools. +- Native tool descriptions omit both backtick- and tilde-fenced usage examples so Agent Zero text envelopes are not presented as function arguments. - Preserve original Agent Zero tool names through the native Responses name map. - Keep MCP tool schemas merged after local prompt-derived tools. - Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available. diff --git a/prompts/agent.system.tool.response.md b/prompts/agent.system.tool.response.md index 195e78c5d..9292882ae 100644 --- a/prompts/agent.system.tool.response.md +++ b/prompts/agent.system.tool.response.md @@ -1,7 +1,7 @@ ### response: final answer to user ends task processing use only when done or no task active -put result in text arg +args: `text` default to balanced, concise answers: informative but tight, not terse and not verbose. usage: ~~~json @@ -17,4 +17,4 @@ usage: } ~~~ -{{ include "agent.system.response_tool_tips.md" }} \ No newline at end of file +{{ include "agent.system.response_tool_tips.md" }} diff --git a/tests/test_responses_tools.py b/tests/test_responses_tools.py index fc187d1f7..9bb3219f0 100644 --- a/tests/test_responses_tools.py +++ b/tests/test_responses_tools.py @@ -146,3 +146,16 @@ def test_responses_function_tools_add_empty_properties_to_mcp_schemas( }, } ] + + +def test_response_tool_native_contract_omits_wrapper_and_exposes_text(): + prompt = (PROJECT_ROOT / "prompts" / "agent.system.tool.response.md").read_text( + encoding="utf-8" + ) + + description = responses_tools._description_from_prompt(prompt, fallback="response") + schema = responses_tools._schema_from_prompt(prompt) + + assert '"tool_name"' not in description + assert "~~~" not in description + assert schema["properties"] == {"text": {"type": "string"}} diff --git a/tests/test_tool_request_normalization.py b/tests/test_tool_request_normalization.py index 41e628496..f982211c5 100644 --- a/tests/test_tool_request_normalization.py +++ b/tests/test_tool_request_normalization.py @@ -131,6 +131,12 @@ def test_extract_tool_request_requires_a_complete_tool_message() -> None: def test_is_misformatted_tool_request_requires_agent_tool_envelope() -> None: request = '{"tool_name":"response","tool_args":{"text":"ok"}}' + concatenated = ( + '{"thoughts":[],"headline":"Inspecting","tool_name":"code_execution_tool",' + '"tool_args":{"code":"pwd"}}' + '{"thoughts":[],"headline":"Answering","tool_name":"response",' + '"tool_args":{"text":"done"}}' + ) malformed = ( '{"thoughts":["Plan the work", "Run the tools", ' '"headline":"Save results", "tool_name":"parallel", ' @@ -140,6 +146,8 @@ def test_is_misformatted_tool_request_requires_agent_tool_envelope() -> None: assert extract_tool_request(malformed) is None assert is_misformatted_tool_request(malformed) is True + assert extract_tool_request(concatenated) is None + assert is_misformatted_tool_request(concatenated) is True assert is_misformatted_tool_request(f"Intro\n```json\n{request}\n```") is True assert is_misformatted_tool_request('{"status":"planning"}') is False assert is_misformatted_tool_request(f"Example: {request}") is False From 4884e26a15fdca3af28e6d40b7a74f0960baa9d7 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:19:10 +0200 Subject: [PATCH 3/4] Add Codex response defaults to OAuth settings Expose reasoning effort, reasoning summary, and answer verbosity in the Codex OAuth provider card, defaulting effort to high.\n\nNormalize provider settings and apply them to Responses requests without overriding explicit request values. --- plugins/_oauth/AGENTS.md | 1 + plugins/_oauth/README.md | 1 + plugins/_oauth/default_config.yaml | 5 ++ plugins/_oauth/helpers/codex.py | 33 ++++++++++- plugins/_oauth/helpers/config.py | 17 ++++++ plugins/_oauth/webui/config.html | 68 ++++++++++++++++++++++ plugins/_oauth/webui/oauth-config-store.js | 3 + tests/test_oauth_codex.py | 58 +++++++++++++++++- tests/test_oauth_static.py | 4 ++ 9 files changed, 186 insertions(+), 4 deletions(-) diff --git a/plugins/_oauth/AGENTS.md b/plugins/_oauth/AGENTS.md index 5264e0820..f7d10aa12 100644 --- a/plugins/_oauth/AGENTS.md +++ b/plugins/_oauth/AGENTS.md @@ -45,6 +45,7 @@ - Local proxy routes must remain loopback or token protected and must not add broad CORS access. - Codex Responses proxy requests must include Codex client metadata and compatibility headers such as `client_metadata`, `x-codex-installation-id`, `originator`, `session-id`, and `thread-id`, and must forward `input` as a list for upstream Codex compatibility. - Codex Responses proxy requests must translate the legacy top-level `reasoning_effort` field to `reasoning.effort`; an explicit native `reasoning` field takes precedence. +- Codex Responses proxy defaults for reasoning effort, reasoning summary, and text verbosity come from the `codex` plugin config; explicit native request values take precedence. ## Work Guidance diff --git a/plugins/_oauth/README.md b/plugins/_oauth/README.md index 4833d6d22..6ebc68eef 100644 --- a/plugins/_oauth/README.md +++ b/plugins/_oauth/README.md @@ -18,6 +18,7 @@ OAuth-backed model providers do not require users to enter API keys. Agent Zero - Writes Codex-compatible credentials to an Agent Zero-owned `auth.json` file. - Refreshes local tokens when needed. - Exposes the local OpenAI-compatible wrapper at `/oauth/codex/v1`. +- Lets users choose default reasoning effort, visible reasoning summaries, and answer verbosity while preserving explicit per-request settings. ### GitHub Copilot (`github_copilot_oauth`) diff --git a/plugins/_oauth/default_config.yaml b/plugins/_oauth/default_config.yaml index e0be463e4..9bce8b09c 100644 --- a/plugins/_oauth/default_config.yaml +++ b/plugins/_oauth/default_config.yaml @@ -25,6 +25,11 @@ codex: models: [] request_timeout_seconds: 120 + # Defaults for Codex Responses requests. Explicit model/request values win. + reasoning_effort: "high" + reasoning_summary: "auto" + text_verbosity: "medium" + # The OpenAI-compatible wrapper is mounted at /oauth/codex/v1. # It is loopback-only by default and does not emit CORS headers. proxy_base_path: "/oauth/codex" diff --git a/plugins/_oauth/helpers/codex.py b/plugins/_oauth/helpers/codex.py index d79025af4..bc59b0100 100644 --- a/plugins/_oauth/helpers/codex.py +++ b/plugins/_oauth/helpers/codex.py @@ -666,9 +666,38 @@ def fetch_models() -> list[str]: def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]: normalized = dict(body) + settings = codex_config() reasoning_effort = normalized.pop("reasoning_effort", None) - if reasoning_effort is not None and "reasoning" not in normalized: - normalized["reasoning"] = {"effort": reasoning_effort} + reasoning = normalized.get("reasoning") + if isinstance(reasoning, dict): + reasoning = dict(reasoning) + elif "reasoning" not in normalized: + reasoning = {} + effort = reasoning_effort or settings.get("reasoning_effort", "high") + if effort != "default": + reasoning["effort"] = effort + else: + reasoning = None + if reasoning is not None: + summary = settings.get("reasoning_summary", "auto") + if summary != "off": + reasoning.setdefault("summary", summary) + if reasoning: + normalized["reasoning"] = reasoning + else: + normalized.pop("reasoning", None) + + verbosity = normalized.pop("verbosity", None) or settings.get( + "text_verbosity", "medium" + ) + text_config = normalized.get("text") + if isinstance(text_config, dict): + text_config = dict(text_config) + if verbosity != "default": + text_config.setdefault("verbosity", verbosity) + normalized["text"] = text_config + elif "text" not in normalized and verbosity != "default": + normalized["text"] = {"verbosity": verbosity} input_value = normalized.get("input") if isinstance(input_value, str): normalized["input"] = ( diff --git a/plugins/_oauth/helpers/config.py b/plugins/_oauth/helpers/config.py index 965de1b12..39d958bb2 100644 --- a/plugins/_oauth/helpers/config.py +++ b/plugins/_oauth/helpers/config.py @@ -19,6 +19,9 @@ DEFAULT_CODEX_SCOPES = [ "api.connectors.read", "api.connectors.invoke", ] +CODEX_REASONING_EFFORTS = {"default", "minimal", "low", "medium", "high", "xhigh"} +CODEX_REASONING_SUMMARIES = {"off", "auto", "concise", "detailed"} +CODEX_TEXT_VERBOSITIES = {"default", "low", "medium", "high"} DEFAULT_GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" DEFAULT_GEMINI_API_SCOPES = [ "openid", @@ -52,6 +55,15 @@ def codex_config(config: dict[str, Any] | None = None) -> dict[str, Any]: "codex_version": _as_str(raw.get("codex_version")), "models": _as_str_list(raw.get("models")), "request_timeout_seconds": _as_int(raw.get("request_timeout_seconds"), 120), + "reasoning_effort": _as_choice( + raw.get("reasoning_effort"), CODEX_REASONING_EFFORTS, "high" + ), + "reasoning_summary": _as_choice( + raw.get("reasoning_summary"), CODEX_REASONING_SUMMARIES, "auto" + ), + "text_verbosity": _as_choice( + raw.get("text_verbosity"), CODEX_TEXT_VERBOSITIES, "medium" + ), "proxy_base_path": _normalize_base_path(raw.get("proxy_base_path"), "/oauth/codex"), "callback_path": _normalize_base_path(raw.get("callback_path"), "/auth/callback"), "require_proxy_token": _as_bool(raw.get("require_proxy_token"), False), @@ -104,6 +116,11 @@ def _as_bool(value: Any, default: bool) -> bool: return default +def _as_choice(value: Any, choices: set[str], default: str) -> str: + normalized = _as_str(value).lower() + return normalized if normalized in choices else default + + def _as_str_list(value: Any) -> list[str]: if value is None: return [] diff --git a/plugins/_oauth/webui/config.html b/plugins/_oauth/webui/config.html index 124af270a..5df9c6415 100644 --- a/plugins/_oauth/webui/config.html +++ b/plugins/_oauth/webui/config.html @@ -124,6 +124,43 @@ +
+
+ Codex response defaults + Applied when an individual model request does not override them. +
+ + + +

Effort controls reasoning depth, summaries appear while the agent works, and verbosity shapes the final answer.

+
+
Enter this code @@ -588,6 +625,35 @@ gap: 8px; } + .oauth-response-defaults { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-panel) 82%, transparent); + } + + .oauth-response-defaults-head, + .oauth-response-defaults .oauth-provider-note { + grid-column: 1 / -1; + } + + .oauth-response-defaults-head { + display: grid; + gap: 2px; + } + + .oauth-response-defaults-head strong { + font-size: 0.84rem; + } + + .oauth-response-defaults-head span { + color: var(--color-text-secondary); + font-size: 0.76rem; + } + .oauth-manual-callback { grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; @@ -611,6 +677,7 @@ } .oauth-provider-input input, + .oauth-provider-input select, .oauth-manual-callback input { width: 100%; min-width: 0; @@ -1162,6 +1229,7 @@ @media (max-width: 720px) { .oauth-device, + .oauth-response-defaults, .oauth-provider-row-detail, .oauth-provider-usage, .oauth-status-row, diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js index bf8349570..36ebb64db 100644 --- a/plugins/_oauth/webui/oauth-config-store.js +++ b/plugins/_oauth/webui/oauth-config-store.js @@ -51,6 +51,9 @@ function ensureConfig(config) { codex.proxy_token = String(codex.proxy_token || ""); codex.codex_version = String(codex.codex_version || ""); codex.models = Array.isArray(codex.models) ? codex.models : []; + codex.reasoning_effort = String(codex.reasoning_effort || "high"); + codex.reasoning_summary = String(codex.reasoning_summary || "auto"); + codex.text_verbosity = String(codex.text_verbosity || "medium"); config.gemini_api = config.gemini_api && typeof config.gemini_api === "object" ? config.gemini_api : {}; const geminiApi = config.gemini_api; diff --git a/tests/test_oauth_codex.py b/tests/test_oauth_codex.py index 5d52734b1..ab0e71c9a 100644 --- a/tests/test_oauth_codex.py +++ b/tests/test_oauth_codex.py @@ -204,16 +204,21 @@ def test_prepare_responses_body_adds_codex_client_metadata(monkeypatch): } assert body["input"] == [{"role": "user", "content": "hello"}] assert body["stream"] is True + assert body["reasoning"] == {"effort": "medium", "summary": "auto"} assert body["include"] == ["output_text", "reasoning.encrypted_content"] @pytest.mark.parametrize( ("request_reasoning", "expected"), [ - ({"reasoning_effort": "xhigh"}, {"effort": "xhigh"}), + ({"reasoning_effort": "xhigh"}, {"effort": "xhigh", "summary": "auto"}), ( {"reasoning": {"effort": "medium"}, "reasoning_effort": "xhigh"}, - {"effort": "medium"}, + {"effort": "medium", "summary": "auto"}, + ), + ( + {"reasoning": {"effort": "medium", "summary": "detailed"}}, + {"effort": "medium", "summary": "detailed"}, ), ], ) @@ -231,6 +236,55 @@ def test_prepare_responses_body_normalizes_reasoning_effort( assert "reasoning_effort" not in body +def test_prepare_responses_body_applies_codex_response_defaults(monkeypatch): + monkeypatch.setattr(codex, "build_client_metadata", lambda: {}) + monkeypatch.setattr( + codex, + "codex_config", + lambda: { + "reasoning_effort": "high", + "reasoning_summary": "concise", + "text_verbosity": "low", + }, + ) + + defaults = codex.prepare_responses_body( + {"model": "gpt-5.5", "input": "hello"}, force_stream=True + ) + overrides = codex.prepare_responses_body( + { + "model": "gpt-5.5", + "input": "hello", + "reasoning": {"effort": "low", "summary": "detailed"}, + "text": {"verbosity": "high"}, + }, + force_stream=True, + ) + + assert defaults["reasoning"] == {"effort": "high", "summary": "concise"} + assert defaults["text"] == {"verbosity": "low"} + assert overrides["reasoning"] == {"effort": "low", "summary": "detailed"} + assert overrides["text"] == {"verbosity": "high"} + + +def test_codex_config_validates_response_defaults(): + from plugins._oauth.helpers.config import codex_config + + config = codex_config( + { + "codex": { + "reasoning_effort": "invalid", + "reasoning_summary": "DETAILED", + "text_verbosity": "low", + } + } + ) + + assert config["reasoning_effort"] == "high" + assert config["reasoning_summary"] == "detailed" + assert config["text_verbosity"] == "low" + + def test_prepare_responses_body_sends_empty_continuation_input_as_list(monkeypatch): monkeypatch.setattr(codex, "build_client_metadata", lambda: {}) diff --git a/tests/test_oauth_static.py b/tests/test_oauth_static.py index 50a01ec91..00360b57d 100644 --- a/tests/test_oauth_static.py +++ b/tests/test_oauth_static.py @@ -61,6 +61,10 @@ def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy(): assert "supports_quota_project" in config_html + store_js assert "OAuth client ID" in config_html assert "quota_project_id" in config_html + store_js + assert "Codex response defaults" in config_html + assert "$store.oauthConfig.codex().reasoning_effort" in config_html + assert "$store.oauthConfig.codex().reasoning_summary" in config_html + assert "$store.oauthConfig.codex().text_verbosity" in config_html assert "providerDetailOpen(card.provider_id)" in config_html + store_js assert "providerDevice(card.provider_id)?.user_code" in config_html assert "submitManualCallback(card.provider_id)" in config_html From 84da8420d269df1d0f5e771e13c7e86ebdad1e57 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:30:41 +0200 Subject: [PATCH 4/4] Refine A0 CLI setup guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep setup conversations brief and progressive so agents ask only for the next required detail. Require the user’s exact Agent Zero URL instead of suggesting a common or default port. --- .../skills/setup-a0-cli/SKILL.md | 164 +++++------------- 1 file changed, 41 insertions(+), 123 deletions(-) diff --git a/plugins/_a0_connector/skills/setup-a0-cli/SKILL.md b/plugins/_a0_connector/skills/setup-a0-cli/SKILL.md index 707ccba91..b6867dba7 100644 --- a/plugins/_a0_connector/skills/setup-a0-cli/SKILL.md +++ b/plugins/_a0_connector/skills/setup-a0-cli/SKILL.md @@ -1,49 +1,28 @@ --- name: setup-a0-cli -description: Guide installing, connecting, or troubleshooting the A0 CLI connector on the user's host machine so Dockerized Agent Zero can work on real local files. Use for install A0, set up A0 CLI, connect local files, remote tools, host-vs-container confusion, or CLI connector setup problems. +description: Briefly guide installing, connecting, or troubleshooting the A0 CLI on the user's host so Dockerized Agent Zero can work with real local files. Use for install A0, enable the host connector, connect local files, remote tools, host-vs-container confusion, or A0 CLI setup problems. --- # A0 CLI Host Setup -Use this skill to guide the user through installing `a0` on their host machine and connecting it to Agent Zero. +`a0` runs on the user's host machine; Agent Zero stays in Docker or its sandbox. -## Core Boundary +## Keep The Conversation Short -- Agent Zero stays in Docker or its sandboxed runtime. -- `a0` installs and runs on the user's host machine. -- The whole point is to let Agent Zero work on the real files on the user's computer. +- Match the user's brevity. For a brief question, reply with one short sentence and at most one command block. +- Give only the next useful step, then wait for the result. Do not dump installation, connection, fallback, troubleshooting, and success details into one response. +- Ask only for information needed now. For a fresh install, ask only which host OS they use. Ask about previous commands and errors only when they say they already tried or something failed. +- Do not add headings, checklists, expected-output prose, warnings, alternatives, or explanations unless they help with the user's current step or the user asks for detail. -## Response Flow +## Fresh Install -### 1. Ask whether they already tried +If the host OS is unknown, ask only: -Start here: +> Which computer are you installing it on: Windows, macOS, or Linux? -> Have you already tried installing `a0`? If so, what command did you run, where did you run it, and what happened? +Once known, give the matching command and tell them to run `a0` when it finishes. -If they already tried, diagnose that attempt before repeating instructions. - -### 2. Stop container installs immediately - -If the user is inside the Agent Zero container, `/a0`, `docker exec`, or another sandbox shell, stop and say: - -> `a0` does not get installed inside the Agent Zero container. Exit to your normal host terminal first. Agent Zero stays in Docker; `a0` belongs on your machine. - -Do not keep giving install commands until they are back on the host. - -### 3. Identify the host OS only as needed - -If the platform is unclear, ask one short question: - -> Are you on macOS/Linux shell or Windows PowerShell on the host machine? - -Then use the matching installer. - -### 4. Use the installer-first flow - -Treat these public installer URLs as placeholders for now. Use them first, but be ready to switch to the manual `uv tool install` path if the raw GitHub URL is blocked, private, or unreachable. - -macOS / Linux: +macOS / Linux host terminal: ```bash curl -LsSf https://raw.githubusercontent.com/agent0ai/a0-connector/main/install.sh | sh @@ -55,110 +34,49 @@ Windows PowerShell: irm https://raw.githubusercontent.com/agent0ai/a0-connector/main/install.ps1 | iex ``` -The installer will install `uv` if needed, then run `uv tool install --upgrade ` for the CLI. +If the user is inside `/a0`, `docker exec`, or another container shell, stop there: tell them to exit and run the installer in their normal computer terminal. Do not give container installation commands. -### 5. Use the manual `uv tool install` fallback when needed +If `a0` is not found after installation, ask them to open a new terminal and try `a0` again. -If the placeholder installer URL is unavailable, switch to a manual `uv tool install` flow instead of stopping. +## Connect -Public Git fallback: +Running `a0` opens a host picker and discovers local Docker instances when possible. Tell the user to select the instance it finds. -```bash -uv tool install --upgrade git+https://github.com/agent0ai/a0-connector -``` +If manual entry is needed: -Local checkout or internal mirror examples: +- Use the exact URL the user currently uses to open Agent Zero, including its actual port. +- Never guess, prescribe, or describe any port as common or default. Do not turn a documentation example into the user's address. +- If the URL is unknown, ask the user to copy it from their browser or Docker's published-port mapping. +- A tunnel URL is pasted exactly as shown and does not need a port appended. -```bash -uv tool install --upgrade /path/to/a0-connector -uv tool install --upgrade git+ssh://git.example.com/team/a0-connector.git -``` +Mention `AGENT_ZERO_HOST` only if the user asks to prefill the picker. Use their exact known URL, never an invented example. -If they want to reuse the stock installer with a custom package source, explain that the installer honors `A0_PACKAGE_SPEC`. +## Troubleshoot Only When Needed -### 6. Tell them to run `a0` +- For a failed attempt, ask for the command, whether it ran on the host or in Docker, and the exact output. +- If the installer URL is unreachable but `uv` works, use: -After install, the next step is always: + ```bash + uv tool install --upgrade git+https://github.com/agent0ai/a0-connector + ``` -```bash -a0 -``` +- A connector `404` usually means the running Agent Zero build lacks the bundled `_a0_connector`; tell the user to update Agent Zero. +- If discovery fails, ask for the exact Agent Zero URL or suggest a Flare Tunnel only then. The Flare Tunnel flow is `Settings > External Services > Flare Tunnel` → `Create Tunnel` → paste the shown HTTPS URL into `a0`. -If the command is not found yet, tell them to open a new terminal and run `a0` again. +## Response Examples -### 7. Explain how to connect +User: "How to enable host connector" -Tell the user what to expect: +> Which computer are you installing it on: Windows, macOS, or Linux? -- `a0` opens the host picker first. -- If Agent Zero is running locally, `a0` may discover it automatically. -- If not, the user can enter the Agent Zero web URL manually in the custom URL field. -- The custom URL can be either a normal address with a port, such as `http://localhost:50001`, or a tunnel URL. -- For Flare Tunnel, tell the user to open `Settings > External Services > Flare Tunnel`, click `Create Tunnel`, then copy and paste the HTTPS URL into `a0` exactly as shown. -- Tunnel URLs such as `https://example.trycloudflare.com` do not need a port appended. -- `AGENT_ZERO_HOST` can prefill the target host without bypassing the picker. +User: "Linux" -Example: +> Run this in your normal Linux terminal, then run `a0` and select the Agent Zero instance it finds. +> +> ```bash +> curl -LsSf https://raw.githubusercontent.com/agent0ai/a0-connector/main/install.sh | sh +> ``` -```bash -export AGENT_ZERO_HOST=http://localhost:50001 -a0 -``` +User: "It asks for a custom URL" -Tunnel example: - -```bash -export AGENT_ZERO_HOST=https://example.trycloudflare.com -a0 -``` - -### 8. Define success clearly - -Successful setup looks like this: - -- `a0` starts on the host machine. -- It connects to the user's Agent Zero instance or reaches the login step. -- The user can open a chat from the terminal. -- Agent Zero can now act on real files on the host through the connector flow while Agent Zero itself still runs in Docker. - -## Troubleshooting - -- If the user says they installed inside Docker or shows `/a0` paths, redirect them to the host-machine install. -- If `a0` gets a connector `404`, explain that the running Agent Zero build likely does not include the builtin `_a0_connector` support yet and should be updated. -- If the browser UI works but `a0` does not, remind them the web UI can run without connector support but the CLI cannot. -- If Docker discovery does not find the instance, have them enter the exact Agent Zero URL with `host:port`, or create a Flare Tunnel in `Settings > External Services > Flare Tunnel` and paste that HTTPS URL directly. - -## Example Requests And Responses - -### Example 1 - -User: "Help me set up the A0 CLI connector." - -Respond like this: - -1. Ask whether they already tried and whether they are on the host machine. -2. If the OS is unknown, ask whether they are in macOS/Linux shell or Windows PowerShell. -3. Give the matching installer command. -4. Tell them to run `a0`. -5. Explain what the host picker and successful connection should look like. - -### Example 2 - -User: "I'm inside the Agent Zero container. How do I install A0?" - -Respond like this: - -- Stop the flow. -- Explain that `a0` must be installed on the host, not in the container. -- Tell them to exit Docker, open a normal terminal on the machine, then continue with the host installer. - -### Example 3 - -User: "The raw GitHub installer URL is blocked on our network." - -Respond like this: - -- Say the public installer URL is only a placeholder path. -- Switch to a manual `uv tool install --upgrade ` flow. -- Offer examples for a local checkout, internal Git host, or the public Git URL if that one works. -- Then tell them to run `a0` and connect to Agent Zero. +> Paste the exact URL you currently use to open Agent Zero in your browser, including its port.