From 7db39fd2888023bc7c279576e656de6a51f6cfac Mon Sep 17 00:00:00 2001 From: jcy <34598069+jiangchangyue@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:48:03 +0800 Subject: [PATCH] fix issues #17, #18 --- CHANGELOG.md | 7 ++ README.md | 5 ++ README.zh.md | 5 ++ docs/reference/api.mdx | 4 ++ docs/reference/kit.mdx | 2 + docs/zh/reference/kit.mdx | 1 + qitos/core/tool.py | 7 +- qitos/core/tool_schema.py | 2 + qitos/kit/parser/parser_utils.py | 78 +++++++++++++++++++--- qitos/kit/parser/react_parser.py | 29 +++++++- tests/test_memory_and_parser_and_critic.py | 39 +++++++++++ tests/test_tool_registry_and_toolset.py | 53 +++++++++++++++ 12 files changed, 222 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e5ef35..1986db27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ How to update: - Move `Unreleased` notes into a dated or versioned section when publishing a release - Prefer user-facing changes, upgrade notes, and important engineering changes over low-level edit logs +## Unreleased + +### Fixed + +- Fixed OpenAI-compatible tool schema export so `Any` and `**kwargs` no longer produce invalid `{"type": "any"}` entries in native `tools=` payloads. +- Fixed `ReActTextParser` so common ReAct variants such as `Action Input` blocks, XML-style action tags, and fenced JSON tool calls no longer fall into parser repair loops. + ## v0.8.0 (2026-06-18) ### Added diff --git a/README.md b/README.md index 7730ba1c..f74c2ca2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ QitOS core is the small framework. Product-grade applications and showcase agent [Quickstart](https://qitor.mintlify.app/quickstart) · [Tutorial Track](https://qitor.mintlify.app/tutorials) · [Benchmarks](https://qitor.mintlify.app/benchmarks/overview) · [CLI Reference](https://qitor.mintlify.app/reference/cli) · [Changelog](CHANGELOG.md) · [Chinese README](README.zh.md) +## Latest Updates + +- **Native tool schema hardening**: OpenAI-compatible `tools=` payloads no longer export invalid `type: any` schemas for `Any` or `**kwargs` parameters. +- **ReAct parser compatibility**: `ReActTextParser` now accepts common `Action Input`, XML-style action tags, and fenced JSON tool-call variants that some OpenAI-compatible models emit. + ## What's New in v0.8.0 - **Architecture-clean stable release**: v0.8.0 documents package ownership, large-file hotspots, optional dependency boundaries, and release guardrails for contributors. diff --git a/README.zh.md b/README.zh.md index 310163fd..6cb09cc0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -16,6 +16,11 @@ QitOS 主仓库是小而清晰的核心框架。产品级 / 展示级应用会 [快速开始](https://qitor.mintlify.app/zh/quickstart) · [教程课程](https://qitor.mintlify.app/zh/tutorials) · [基准测试](https://qitor.mintlify.app/zh/benchmarks/overview) · [CLI 参考](https://qitor.mintlify.app/zh/reference/cli) · [更新日志](CHANGELOG.md) · [English README](README.md) +## 最新进展 + +- **原生工具 schema 加固**:OpenAI-compatible `tools=` payload 不再为 `Any` 或 `**kwargs` 参数导出非法的 `type: any` schema。 +- **ReAct 解析兼容性**:`ReActTextParser` 现在可以解析部分 OpenAI-compatible 模型常见的 `Action Input`、XML 风格 action 标签和围栏 JSON tool-call 变体。 + ## v0.8.0 最新进展 - **架构清洁稳定版**:v0.8.0 记录包边界、超大文件热点、可选依赖边界和发布守护规则,方便贡献者判断代码归属。 diff --git a/docs/reference/api.mdx b/docs/reference/api.mdx index abee32b2..672e7a30 100644 --- a/docs/reference/api.mdx +++ b/docs/reference/api.mdx @@ -727,6 +727,10 @@ def setup(self, context: Optional[Dict[str, Any]] = None) -> None def teardown(self, context: Optional[Dict[str, Any]] = None) -> None ``` +`get_all_specs()` returns OpenAI-compatible function specs for native tool delivery. +Unbounded `**kwargs` parameters are omitted from the exported schema, and `Any` +parameters are rendered without the invalid JSON Schema type `any`. + **Example** ```python diff --git a/docs/reference/kit.mdx b/docs/reference/kit.mdx index 10fea9e6..ca7a8e97 100644 --- a/docs/reference/kit.mdx +++ b/docs/reference/kit.mdx @@ -95,6 +95,8 @@ Prompt format and parser must match exactly. If your prompt asks for `Thought:` Parses ReAct-style text output with labeled blocks such as `Thought:` and `Action:`. +It also accepts common action variants such as `Action Input: {...}`, +XML-style `...` tags, and fenced JSON tool-call objects. ```python from qitos.kit import ReActTextParser diff --git a/docs/zh/reference/kit.mdx b/docs/zh/reference/kit.mdx index b170c061..cab4e72f 100644 --- a/docs/zh/reference/kit.mdx +++ b/docs/zh/reference/kit.mdx @@ -75,6 +75,7 @@ from qitos.harness import build_harness_policy, build_model_for_preset 用于解析带 `Thought:`、`Action:`、`Final Answer:` 标记的 ReAct 文本。 +同时兼容常见 action 变体,例如 `Action Input: {...}`、XML 风格的 `...` 标签,以及围栏 JSON tool-call 对象。 ```python from qitos.kit import ReActTextParser diff --git a/qitos/core/tool.py b/qitos/core/tool.py index e3671659..a8772e76 100644 --- a/qitos/core/tool.py +++ b/qitos/core/tool.py @@ -520,7 +520,12 @@ def build_tool_spec(func: Callable[..., Any], meta: ToolMeta) -> ToolSpec: "process_ops", }: continue - params[name] = {"type": _type_to_json(p.annotation), "description": ""} + if p.kind is inspect.Parameter.VAR_KEYWORD: + continue + json_type = _type_to_json(p.annotation) + params[name] = {"description": ""} + if json_type != "any": + params[name]["type"] = json_type if p.default is inspect.Parameter.empty: required.append(name) diff --git a/qitos/core/tool_schema.py b/qitos/core/tool_schema.py index fd2d88e8..a771a458 100644 --- a/qitos/core/tool_schema.py +++ b/qitos/core/tool_schema.py @@ -42,6 +42,8 @@ def function_schema(func: Any) -> Dict[str, Any]: for name, p in sig.parameters.items(): if name in skip: continue + if p.kind is inspect.Parameter.VAR_KEYWORD: + continue annotation = hints.get(name, p.annotation) schema = type_to_json_schema(annotation) entry: Dict[str, Any] = dict(schema) diff --git a/qitos/kit/parser/parser_utils.py b/qitos/kit/parser/parser_utils.py index 14f24ec2..31459b2e 100644 --- a/qitos/kit/parser/parser_utils.py +++ b/qitos/kit/parser/parser_utils.py @@ -10,6 +10,9 @@ from qitos.kit.parser.func_parser import parse_first_action_invocation +_ACTION_INPUT_KEYS = ("actioninput", "toolinput", "input", "args", "arguments") +_BARE_ACTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") + def norm(token: str) -> str: return re.sub(r"[\s_\-]+", "", token.strip().lower()) @@ -53,17 +56,74 @@ def parse_action_any(blob: str) -> Optional[Dict[str, Any]]: if parsed is not None: return parsed - obj = parse_object_like(text) - if isinstance(obj, dict): - name = obj.get("name") - args = obj.get("args", {}) - if isinstance(name, str): - if not isinstance(args, dict): - args = {} - return {"name": name, "args": args} + obj = parse_object_like(strip_code_fences(text)) + action = coerce_action_object(obj) + if action is not None: + return action + + obj, _warnings = parse_jsonish_object(text) + action = coerce_action_object(obj) + if action is not None: + return action + + action = parse_labeled_action_input(text) + if action is not None: + return action return None +def coerce_action_object(obj: Any) -> Optional[Dict[str, Any]]: + if not isinstance(obj, dict): + return None + name = obj.get("name") + args = obj.get("args", {}) + if isinstance(name, str): + if not isinstance(args, dict): + args = {} + return {"name": name, "args": args} + actions = extract_json_actions(obj) + if actions: + return actions[0] + return None + + +def parse_labeled_action_input(text: str) -> Optional[Dict[str, Any]]: + blocks = extract_labeled_blocks(text) + action_text = first_block_value(blocks, ("action", "tool", "call")) + if not action_text: + return None + + parsed = parse_first_action_invocation(f"Action: {action_text}") + if parsed is not None: + return parsed + + nested = parse_action_any(action_text) + if nested is not None: + return nested + + if not _BARE_ACTION_NAME.fullmatch(action_text): + return None + + action_input = first_block_value(blocks, _ACTION_INPUT_KEYS) + return {"name": action_text, "args": parse_action_input_args(action_input)} + + +def parse_action_input_args(action_input: Optional[str]) -> Dict[str, Any]: + if not action_input: + return {} + payload, _warnings = parse_jsonish_object(action_input) + if not isinstance(payload, dict): + return {} + if len(payload) == 1: + only_value = next(iter(payload.values())) + if isinstance(only_value, dict) and norm(next(iter(payload.keys()))) in { + "args", + "arguments", + }: + return only_value + return payload + + def parse_object_like(text: str) -> Optional[Any]: try: return json.loads(text) @@ -266,6 +326,8 @@ def parse_xml_action( parsed = parse_action_any(body) if parsed is not None: return parsed + if _BARE_ACTION_NAME.fullmatch(body): + return {"name": body, "args": {}} return None diff --git a/qitos/kit/parser/react_parser.py b/qitos/kit/parser/react_parser.py index d0f39dcf..d2b8e3bf 100644 --- a/qitos/kit/parser/react_parser.py +++ b/qitos/kit/parser/react_parser.py @@ -9,8 +9,11 @@ from qitos.kit.parser.parser_utils import ( extract_labeled_blocks, first_block_value, + first_xml_text, norm, parse_action_any, + parse_xml_action, + parse_xml_root, ) @@ -75,7 +78,31 @@ def parse( if final_answer: return Decision.final(answer=final_answer, rationale=thought, meta=meta) - action = parse_action_any(action_blob or text) + if "<" in text and ">" in text: + try: + root = parse_xml_root(text) + except Exception: + root = None + if root is not None: + xml_thought = thought or first_xml_text(root, self.thought_keys) + xml_reflection = reflection or first_xml_text( + root, self.reflection_keys + ) + xml_meta = {"reflection": xml_reflection} if xml_reflection else {} + xml_final = first_xml_text(root, self.final_keys) + if xml_final: + return Decision.final( + answer=xml_final, rationale=xml_thought, meta=xml_meta + ) + xml_action = parse_xml_action(root, self.action_keys) + if xml_action is not None: + return Decision.act( + actions=[xml_action], rationale=xml_thought, meta=xml_meta + ) + + action = parse_action_any(text) + if action is None and action_blob: + action = parse_action_any(action_blob) if action is not None: return Decision.act(actions=[action], rationale=thought, meta=meta) return parser_wait_decision( diff --git a/tests/test_memory_and_parser_and_critic.py b/tests/test_memory_and_parser_and_critic.py index a384db00..eb182ab9 100644 --- a/tests/test_memory_and_parser_and_critic.py +++ b/tests/test_memory_and_parser_and_critic.py @@ -103,6 +103,45 @@ def test_json_decision_parser_salvages_wrapped_json_like_blocks(): assert decision.meta["parser_diagnostics"]["salvage_applied"] is True +def test_react_parser_accepts_common_action_variants_from_issue_18(): + parser = ReActTextParser() + + langchain = parser.parse( + 'Thought: I need to inspect the file.\n' + 'Action: read_file\n' + 'Action Input: {"filename": "buggy_module.py"}' + ) + assert langchain.mode == "act" + assert langchain.actions[0] == { + "name": "read_file", + "args": {"filename": "buggy_module.py"}, + } + assert langchain.rationale == "I need to inspect the file." + + xml = parser.parse( + "\n" + "Let me first read buggy_module.py to understand the bug.\n" + "\n\n" + "\n" + "list_files\n" + "" + ) + assert xml.mode == "act" + assert xml.actions[0] == {"name": "list_files", "args": {}} + assert xml.rationale == "Let me first read buggy_module.py to understand the bug." + + fenced_json = parser.parse( + '```json\n' + '{"name": "read_file", "args": {"filename": "buggy_module.py"}}\n' + '```' + ) + assert fenced_json.mode == "act" + assert fenced_json.actions[0] == { + "name": "read_file", + "args": {"filename": "buggy_module.py"}, + } + + def test_func_parser_handles_nested_and_truncated_calls(): s = "a=1, payload={'x':[1,2,3], 'y':'k,v'}, html='
(x)
', flag=true" parts = split_args_robust(s) diff --git a/tests/test_tool_registry_and_toolset.py b/tests/test_tool_registry_and_toolset.py index 0a908dc0..72a16109 100644 --- a/tests/test_tool_registry_and_toolset.py +++ b/tests/test_tool_registry_and_toolset.py @@ -4,6 +4,7 @@ import pytest from qitos import Action, AgentModule, Decision, Engine, StateSchema, ToolRegistry, tool +from qitos.core import function_tool from qitos.engine import RuntimeBudget from qitos.kit import tool as tool_pkg from qitos.kit.tool import ( @@ -139,6 +140,58 @@ def test_curated_toolsets_register_cleanly(tmp_path): ), f"{toolset.__class__.__name__} registered no tools" +def test_coding_toolset_openai_specs_do_not_emit_invalid_any_schema(tmp_path): + registry = ToolRegistry() + registry.include(CodingToolSet(workspace_root=str(tmp_path), auto_approve=True)) + + offenders: dict[str, list[str]] = {} + kwargs_tools: dict[str, dict[str, Any]] = {} + for spec in registry.get_all_specs(): + function = spec["function"] + parameters = function.get("parameters") or {} + properties = parameters.get("properties", {}) + any_params = [ + name + for name, schema in properties.items() + if isinstance(schema, dict) and schema.get("type") == "any" + ] + if any_params: + offenders[function["name"]] = any_params + if function["name"] in { + "agent_spawn", + "cron_create", + "cron_delete", + "lsp_query", + }: + kwargs_tools[function["name"]] = parameters + + assert offenders == {} + assert set(kwargs_tools) == { + "agent_spawn", + "cron_create", + "cron_delete", + "lsp_query", + } + for parameters in kwargs_tools.values(): + assert "kwargs" not in parameters.get("properties", {}) + assert "kwargs" not in parameters.get("required", []) + + +def test_function_tool_schema_uses_valid_json_schema_for_any_and_kwargs(): + @function_tool(name="accept_any") + def accept_any(payload: Any, **kwargs: Any) -> dict[str, Any]: + return {"payload": payload, "extra": kwargs} + + spec = accept_any.spec.input_schema + properties = spec["properties"] + + assert "payload" in properties + assert properties["payload"].get("type") != "any" + assert "payload" in spec["required"] + assert "kwargs" not in properties + assert "kwargs" not in spec["required"] + + def test_tool_package_does_not_export_uncurated_cyber_toolsets(): exported = set(getattr(tool_pkg, "__all__", [])) assert "ReportToolSet" in exported