Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 记录包边界、超大文件热点、可选依赖边界和发布守护规则,方便贡献者判断代码归属。
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/kit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ Prompt format and parser must match exactly. If your prompt asks for `Thought:`
<Accordion title="ReActTextParser">

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 `<action>...</action>` tags, and fenced JSON tool-call objects.

```python
from qitos.kit import ReActTextParser
Expand Down
1 change: 1 addition & 0 deletions docs/zh/reference/kit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ from qitos.harness import build_harness_policy, build_model_for_preset
<Accordion title="ReActTextParser">

用于解析带 `Thought:`、`Action:`、`Final Answer:` 标记的 ReAct 文本。
同时兼容常见 action 变体,例如 `Action Input: {...}`、XML 风格的 `<action>...</action>` 标签,以及围栏 JSON tool-call 对象。

```python
from qitos.kit import ReActTextParser
Expand Down
7 changes: 6 additions & 1 deletion qitos/core/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions qitos/core/tool_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 70 additions & 8 deletions qitos/kit/parser/parser_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
29 changes: 28 additions & 1 deletion qitos/kit/parser/react_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions tests/test_memory_and_parser_and_critic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<thought>\n"
"Let me first read buggy_module.py to understand the bug.\n"
"</thought>\n\n"
"<action>\n"
"list_files\n"
"</action>"
)
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='<div>(x)</div>', flag=true"
parts = split_args_robust(s)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_tool_registry_and_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
Loading