Metadata-Version: 2.4
Name: nonoka
Version: 1.3.7
Summary: A production-grade, type-safe Python Agent framework
Project-URL: Homepage, https://github.com/fyerfyer/nonoka
Project-URL: Documentation, https://github.com/fyerfyer/nonoka#readme
Project-URL: Repository, https://github.com/fyerfyer/nonoka
Project-URL: Issues, https://github.com/fyerfyer/nonoka/issues
Author-email: fyerfyer <fyerfyer@126.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agent,ai,llm,mcp,orchestration
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: anyio>=4.13.0
Requires-Dist: fastapi>=0.115.0
Requires-Dist: litellm>=1.86.2
Requires-Dist: mcp>=1.27.2
Requires-Dist: mem0ai>=2.0.4
Requires-Dist: opentelemetry-api>=1.28.0
Requires-Dist: orjson>=3.10.0
Requires-Dist: psycopg[binary]>=3.2.0
Requires-Dist: pydantic-settings>=2.14.1
Requires-Dist: pydantic>=2.13.4
Requires-Dist: python-dotenv>=1.2.2
Requires-Dist: pyyaml>=6.0
Requires-Dist: structlog>=25.5.0
Requires-Dist: tomli>=2.0.0; python_version < '3.11'
Requires-Dist: uvicorn>=0.30.0
Provides-Extra: eval
Requires-Dist: datasets>=3.0.0; extra == 'eval'
Provides-Extra: repo-map
Requires-Dist: multilspy>=0.0.15; extra == 'repo-map'
Requires-Dist: tree-sitter-language-pack>=1.0.0; extra == 'repo-map'
Requires-Dist: tree-sitter>=0.25.0; extra == 'repo-map'
Description-Content-Type: text/markdown

# Nonoka

English | [简体中文](README.zh-CN.md)

A production-grade, type-safe Python agent framework with deterministic orchestration, conversational execution, and first-class MCP integration.

## Features

- **Type-safe core** — Pydantic-validated schemas throughout; agents, tools, and plans are all strongly typed
- **Deterministic orchestration** — `Plan` + `Step` + `ref()` for explicit control flow, not just prompt-and-pray
- **Conversational execution** — `ReActAgent`, `ReflectiveAgent`, and `PlanExecutor` paradigms out of the box
- **First-class tools** — `@tool` decorator with automatic Pydantic schema generation
- **Prompt engineering** — `@prompt` decorator and `PromptTemplate` for composable, type-safe prompt construction
- **MCP ready** — built-in MCP (Model Context Protocol) lifecycle manager (`MCPManager`) and client (`MCPClient`)
- **Lazy skills** — discover and register skills without bloating the system prompt; load full guidance on demand via the `load_skill` tool
- **External capabilities** — delegate tool execution to a host/frontend (e.g. OpenCode) using `ExternalCapability` and `resume_external_tools()`
- **Resilient execution** — structured error taxonomy (`TransientError`, `LogicError`, `SafetyError`, etc.) with configurable `RetryPolicy`
- **Observable hooks** — `Hooks` system for tracing, logging, and custom middleware
- **Multi-backend LLM** — powered by `litellm`, supporting OpenAI, Anthropic, DeepSeek, and 100+ providers

## Installation

```bash
pip install nonoka
```

Or with uv:

```bash
uv add nonoka
```

## Quick Start

```python
import asyncio
import nonoka

@nonoka.tool
async def get_weather(city: str) -> str:
    """Get the weather for a city."""
    return f"Sunny in {city}!"

# Sync functions are also supported
@nonoka.tool
def get_time() -> str:
    """Get the current time."""
    return "It's noon."

async def main():
    agent = nonoka.Agent(
        model="gpt-4o",
        tools=[get_weather, get_time],
    )
    runner = nonoka.Runner()          # execution coordinator
    result = await runner.run_react(agent, "What's the weather in Tokyo?", deps=None)
    print(result.data)                # result.data (not result.output)

asyncio.run(main())
```

> **Key concept:** `Agent` is a pure configuration object.  Execution is handled by `Runner`, which owns the LLM provider, checkpoint store, and memory backend.

## Plans & Orchestration

Explicit multi-step workflows with type-safe references, executed deterministically via `Runner.run_plan`:

```python
from nonoka import PlanBuilder, ref, Runner

plan = (
    PlanBuilder(objective="Research workflow")
    .step("research", search_tool, query="Latest AI breakthroughs")
    .step("summarize", summarize_tool, content=ref("research"))
    .build()
)

runner = Runner()
result = await runner.run_plan(agent, plan=plan, deps=None)
print(result.data)
```

## Prompt Templates

Composable, type-safe prompts:

```python
from nonoka import prompt, PromptTemplate

@prompt
def translate(text: str, target: str = "Chinese") -> str:
    """Translate the following text to {target}:

    {text}
    """

# Or programmatically with Jinja2 syntax
tpl = PromptTemplate("Summarize this in {{style}}:\n{{content}}")
output = tpl.render(style="bullet points", content=long_text)
```

## ReAct Agent

```python
from nonoka import Agent, tool, Runner

@tool
async def search(query: str) -> dict:
    ...

@tool
async def calculator(expr: str) -> float:
    ...

agent = Agent(model="gpt-4o", tools=[search, calculator])
runner = Runner()
result = await runner.run_react(agent, "What is 42 * the current temperature in Paris?", deps=None)
print(result.data)
```

## Tool Responses

Tools can return plain values or a `ToolResponse` to communicate pagination and metadata to the agent loop:

```python
from nonoka import ToolResponse, tool

@tool
async def search_web(ctx, query: str, cursor: str | None = None) -> ToolResponse:
    results, next_cursor = await _do_search(query, cursor)
    return ToolResponse(
        data={"results": results, "query": query},
        has_more=next_cursor is not None,
        next_cursor=next_cursor,
        suggested_next_step="Summarise the findings and stop searching."
        if len(results) >= 5 else "Refine query and search again.",
    )
```

## Stateful tools and execution traces

Tools can declare execution semantics.  Explicit reads may run concurrently;
stateful, mutating, exclusive, and unknown capabilities are serialized in
deterministic source order.

```python
from nonoka import ToolExecution, tool

@tool(execution=ToolExecution(stateful_action=True, mutates_workspace=True))
async def run_terminal(command: str) -> str:
    ...

@tool(execution=ToolExecution(read_only=True, pagination=True))
async def read_log(cursor: str | None = None) -> str:
    ...
```

Each `RunResult` carries a bounded, credential-redacted `trace`.  It includes
LLM request/response usage, tool timings/results, verifier outcomes, and the
final termination reason, making it suitable for benchmark artifacts without
leaking API keys.

```python
result = await Runner().run_react(agent, "Inspect and fix the service", deps=None)
print(result.trace["termination"])
```

## Production observability

`Runner` can persist redacted prompts, responses, tool I/O, errors, token
usage, and LiteLLM cost estimates. OpenTelemetry spans are emitted for runs,
model requests, and tool calls when an SDK tracer provider is configured.

```python
from nonoka import ObservabilityPipeline, Runner, SQLiteEventStore

pipeline = ObservabilityPipeline(
    SQLiteEventStore(".nonoka/events.db"),
    exporters=[my_exporter],  # Langfuse, OTLP, or another TelemetryExporter
)
runner = Runner(observability=pipeline)
```

Exporters are optional and best-effort, so a telemetry backend outage does not
interrupt agent execution.

## ASGI service and safety policy

The authenticated FastAPI service exposes `/run`, streaming `/chat`, `/tasks`,
`/health`, and Prometheus-compatible `/metrics` endpoints:

```bash
export NONOKA_API_TOKEN="replace-with-a-long-random-token"
uvicorn nonoka.server.app:create_app --factory --host 0.0.0.0 --port 8000
```

Filesystem and command checks can also be reused by hosts before executing a
tool:

```python
from pathlib import Path
from nonoka import SafetyPolicy

policy = SafetyPolicy(allowed_roots=[Path.cwd()])
policy.check_path("src/app.py")
decision = policy.check_command("pytest -q")  # "allow" or "approval"
```

## Optional loop extensions

The default loop retains its conservative tool scheduler and progress guard.
Optional extensions can add bounded feedback at well-defined points without
changing tool calls, concurrency, or run budgets.  Their decisions are also
recorded in `result.trace["extensions"]`.

```python
from nonoka import Agent, Runner
from nonoka.ext.coding import VerifierRepairExtension

# evaluator implements: async evaluate(RunResult) -> EvaluationResult
agent = Agent(
    model="gpt-4o",
    tools=[...],
    extensions=[VerifierRepairExtension(evaluator, max_repairs=2)],
)
result = await Runner().run_react(agent, "Implement and verify the fix", deps=None)
```

`VerifierRepairExtension` requests another normal ReAct turn only after a
deterministic verifier fails. `ResponseGroundingExtension` can similarly
validate a final natural-language claim against tool-established state. Use
`CodingWorkflow` (or `CodeStrategyRouter`) to choose `direct`,
`tool_assisted`, or `verified_repair` from caller-known task capabilities.
The default is deliberately conservative: standalone code is direct, a
workspace task is tool-assisted, and repair requires a workspace plus a
deterministic evaluator. `TerminalCodingWorkflow` additionally requires the
caller to provide an explicit `verify_command`; it never guesses a test
command from the prompt. `TerminalCommandEvaluator` can wrap that approved
command and a caller-owned terminal executor to return structured test
failures for the bounded repair extension.

## Gateway (IM Platform Integration)

`Gateway` standardizes requests from QQ, Telegram, Discord, etc. and routes them to Agents, then pushes Agent outputs back to the original platforms.

```python
from nonoka.ext.gateway.core import Gateway
from nonoka.ext.gateway.limiter import TokenBucketLimiter

runner = Runner()
gateway = Gateway(runner, limiter=TokenBucketLimiter(default_rate=1, default_burst=3))
gateway.register_adapter(TelegramAdapter(token="..."))
gateway.set_default_agent(agent)

await gateway.start()
```

## Configuration

Nonoka supports three ways to configure agents: **declarative files** (YAML/JSON/TOML), **fluent builders**, and **direct code**.

### Declarative Config (YAML)

Write a `nonoka.yaml` and load it:

```yaml
# nonoka.yaml
agents:
  weather_assistant:
    model: gpt-4o
    system_prompt: "You are a weather assistant."
    max_turns: 10
    tools:
      - import: my_tools.weather:get_weather

  code_assistant:
    model: deepseek/deepseek-v4-pro
    system_prompt: "You are a coding assistant."

# Runner backend configuration (defaults are SQLite persistent)
# Use "memory" / "disabled" for testing
runner:
  checkpoint: sqlite        # or "memory", "disabled"
  memory: sqlite            # or "in_memory", "disabled"

defaults:
  model: deepseek/deepseek-v4-pro
  max_turns: 10
```

```python
from nonoka import Config

config = Config.load("nonoka.yaml")           # or Config.auto_find()
agent = config.agents["weather_assistant"].build()
runner = config.runner.build()
```

Single-agent shorthand (no `agents:` dict needed):

```yaml
agent:
  model: gpt-4o
  system_prompt: "You are helpful."
```

```python
agent = config.agent.build()
```

### Environment Variables in Config

Use `${VAR}` or `${VAR:-default}` in YAML values:

```yaml
agent:
  model: ${NONOKA_MODEL:-gpt-4o}
  system_prompt: ${NONOKA_PROMPT}
```

### Fluent Builder API

```python
from nonoka import AgentBuilder, ToolRegistry, tool

@tool
async def get_weather(city: str) -> str:
    return f"Sunny in {city}!"

registry = ToolRegistry()

@registry.register
async def search_city(name: str) -> str:
    return f"Found {name}"

agent = (
    AgentBuilder()
    .model("gpt-4o")
    .system_prompt("You are a weather assistant.")
    .tool(get_weather)
    .tool_registry(registry)                 # add a whole registry
    .tool_by_import("my_tools.search:search_city")
    .max_turns(20)
    .retry(max_retries=5, backoff=1.5)
    .metadata(category="weather")
    .tag("production")
    .build()
)
```

You can also pass a `ToolRegistry` directly to `.tools()`:

```python
agent = AgentBuilder().model("gpt-4o").tools(registry).build()
```

### Skills

Apply pre-packaged skills directly in the builder:

```python
from nonoka import AgentBuilder, Skill

skill = Skill.from_file(".agents/skills/code-review/SKILL.md")

agent = (
    AgentBuilder()
    .model("gpt-4o")
    .system_prompt("You are a senior engineer.")
    .skill(skill)
    # or .skills(skill_a, skill_b)
    .build()
)
```

### Lazy skill loading

For projects with many skills, eagerly merging every skill into the system prompt can explode context length. Use `SkillRegistry` to expose only names and descriptions, and let the model call `load_skill` when it needs the full guidance:

```python
from nonoka import AgentBuilder, SkillRegistry, load_skill

registry = SkillRegistry(enabled=["code-review", "nextjs-best-practices"])

agent = (
    AgentBuilder()
    .model("gpt-4o")
    .skill_manager(registry)
    .tool(load_skill)
    .build()
)
```

Skills are discovered from the Agent Skills layout `<skill-root>/<skill-name>/SKILL.md`. Project `.agents/skills` entries override user-level `~/.agents/skills` entries with the same name. Legacy flat `skills/<name>.md` files remain supported for compatibility.

The `load_skill` tool returns the selected guidance, skill directory, and bundled `scripts/`, `references/`, and `assets/` paths as a context-protected tool result. Discovery reads only skill metadata; tools declared by enabled skills are resolved when the runtime tool catalog is built, while the full guidance remains lazy until activation.

### MCP servers

Connect to external tools and resources via the Model Context Protocol (MCP). nonoka-agent provides a built-in `MCPManager` that handles server lifecycle (start, health checks, restart, shutdown) and exposes discovered tools as ordinary `Capability` objects:

```python
from nonoka import AgentBuilder, Runner
from nonoka.ext.mcp import MCPManager, MCPServerConfig

manager = MCPManager()

configs = {
    "filesystem": MCPServerConfig(
        transport="stdio",
        command="npx",
        args=["-y", "@modelcontextprotocol/server-filesystem", "/home/user/docs"],
    ),
}

async def main():
    tools = await manager.start_all(configs)

    agent = (
        AgentBuilder()
        .model("gpt-4o")
        .system_prompt("Use the filesystem tools when needed.")
        # Register MCP tools individually (or merge them into a ToolRegistry)
        .tools(*[cap for _, cap in tools])
        .build()
    )

    runner = Runner()
    result = await runner.run_react(agent, "List the files in /home/user/docs")
    print(result.data)

    await manager.stop_all()
```

`MCPManager` supports stdio and sse transports, parallel startup, periodic health checks, and exponential-backoff restart.

### External capabilities

Some hosts (e.g. OpenCode) want to own tool execution and human-in-the-loop approval themselves. nonoka-agent supports this via `ExternalCapability`: the framework registers the tool schema and emits the tool call, but execution is delegated to the host. When the host returns a result, the session resumes with `Runner.resume_external_tools()`.

```python
from nonoka import AgentBuilder, Runner, ExternalCapability, ToolExecution

cap = ExternalCapability(
    name="bash",
    description="Run a shell command.",
    parameters={
        "type": "object",
        "properties": {"command": {"type": "string"}},
        "required": ["command"],
    },
    execution=ToolExecution(stateful_action=True, mutates_workspace=True),
)

agent = AgentBuilder().model("gpt-4o").tool(cap).build()
runner = Runner()

# In the caller (e.g. nonoka-cli bridge):
# 1. Run until ExternalToolExecutionRequiredError is raised.
# 2. Forward the tool call to the external host.
# 3. Resume with the host's result. Workspace-mutating tools include a host
#    receipt and before/after workspace attestation.
async for event in runner.resume_external_tools(
    agent,
    deps=None,
    session_id="session-123",
    results={"call_abc": {
        "result": "command completed",
        "exit_code": 0,
        "elapsed_seconds": 0.14,
        "host": "my-terminal-host",
        "workspace": {
            "root": "/workspace",
            "before_digest": "...",
            "after_digest": "...",
            "created": ["solution.py"],
        },
    }},
):
    print(event)
```

`ExternalCapability` carries `external=True` so the ReAct loop pauses instead of invoking the tool locally. This lets nonoka focus on decision-making while the host owns execution, permissions, and TUI rendering. A capability declared with `ToolExecution(mutates_workspace=True)` rejects a resume without this receipt; the receipt is recorded in the redacted trace. It makes the cross-process trust boundary auditable, but does not turn an untrusted host into a sandbox.

#### Partial-observation fallbacks

An external host may explicitly mark a receipt as `completeness="partial"`: for
example, it could only return a preview of a large search result. A host can
register a local, read-only capability as a **declarative observation fallback**
so the next model turn also receives bounded evidence from a compatible local
operation.

```python
from nonoka import tool, ToolExecution

@tool(description="Return small evidence snippets from a bounded local scope.",
      execution=ToolExecution(read_only=True))
async def bounded_probe(ctx, query: str, scope: str, limit: int = 20):
    ...

bounded_probe.metadata = {
    "kind": "observation_fallback",
    "fallback": {
        "on_partial_external": True,
        # fallback argument -> source external-call argument
        "argument_map": {"query": "query", "scope": "directory"},
        "defaults": {"limit": 20},
    },
}
```

On a partial external receipt, Nonoka selects one registered declaration only
when every mapped source argument is present and the fallback is read-only. It
executes that local capability once and attaches its structured result to the
partial observation before resuming the model. The framework does not match on
host name, external tool name, task name, path, or content pattern; those
semantics remain entirely in the capability declaration. A missing mapping,
non-read-only capability, or a complete/unknown receipt simply skips the
fallback.

### Evaluation policy and external benchmarks

Framework code tasks default to direct generation. For a reproducible paired
comparison of the three explicit strategies, use the versioned complex MBPP
slice:

```bash
python -m nonoka.ext.eval compare --dataset mbpp-complex-v1 --model <model> --trials 3
```

Terminal-Bench 2 uses Harbor as the main official runner. Harbor owns the
Docker lifecycle and authoritative job artifact, while Nonoka exports its
trace as ATIF:

```bash
python -m nonoka.ext.eval external run --benchmark terminal-bench \
  --model <model> --task-id sanitize-git-repo --task-id configure-git-webserver
```

For a task whose contract explicitly requires a workspace edit, opt into the
terminal progress reminder rather than enabling it globally. Terminal output is
also bounded before it enters the model context; both controls are adjustable
through Harbor agent kwargs:

```bash
python -m nonoka.ext.eval external run --benchmark terminal-bench --model <model> \
  --task-id sanitize-git-repo --agent-kwarg requires_workspace_mutation=true \
  --agent-kwarg max_exploration_turns=3 --agent-kwarg max_terminal_output_chars=12000
```

The exported ATIF trajectory preserves per-turn tool attribution, bounded
terminal observations, usage, extension decisions, and termination metadata.

Set `NONOKA_HARBOR_BIN` to the dedicated Harbor environment's executable and
run `python -m nonoka.ext.eval doctor` before starting a live benchmark. The
evaluation gate is deliberately two-stage: first run deterministic core/eval
adapter tests, then run isolated official harnesses only after the doctor
check reports their dependencies ready.
`terminal-bench-legacy` remains only for historical 0.1.1 reproduction; do
not compare its scores with Terminal-Bench 2. τ³ final text is checked against
deterministic tool evidence before it is emitted, and EvalPlus remains the
official scorer for HumanEval+/MBPP+.

### Validation snapshot

The following results are retained as an engineering validation record, not a
single leaderboard number: the suites measure different capabilities, and the
model, budget, and verifier remain part of every claim. Scores below are from
the remediation evaluation cycle completed on 2026-07-22.

| Scope | Result | What it establishes |
| --- | --- | --- |
| Deterministic core and eval-adapter regressions | **73 passed** in 3.20 s; subsequent targeted protocol regression **48 passed** | Safe serialization, progress-aware loop detection, redacted trace/usage, external workspace receipts, Harbor/ATIF mapping, and evaluator adapters remain covered without a live model. |
| Terminal-Bench 2 / Harbor | Official `sanitize-git-repo` harness completed in repeated trials with trace and token attribution; rewards **0.0** | The adapter, Docker lifecycle, official verifier, and artifacts work end-to-end. One run exposed and then validated a fix for context trimming that could orphan tool responses; the remaining failures were model task-policy failures (exploration, missed files, or non-exact replacements), not harness failures. |
| Historical Terminal-Bench 0.1.1 | `tmux-advanced-workflow` passed its official verifier | Pager handling, multiline tmux submission, loop handling, and usage aggregation work on the legacy adapter. `fix-git` still missed byte-exact Markdown content generated by the model; it is not counted as an adapter success. |
| τ³ retail | **9/10** tasks passed | Multi-turn, mixed-tool workflows execute under the conservative stateful-tool policy. The remaining failure was an unsupported SKU-count claim in the model's final response. |
| EvalPlus HumanEval+ | base **160/164** (97.56%); plus **150/164** (91.46%) | Official complete-set code-generation scores. |
| EvalPlus MBPP+ | base **369/378** (97.62%); plus **311/378** (82.28%) | Official complete-set code-generation scores. |
| Fixed 20-task complex MBPP slice | direct **12/20**; tool-assisted **11/20**; verified repair **12/20** | The bounded repair workflow restores parity when a deterministic verifier is available, but tool use does not justify replacing direct generation as the default for standalone code. |

The Terminal-Bench 2 controlled retry with a six-turn cap reduced the same
task's trajectory to 7,169 input and 574 output tokens (versus 110,089 and
1,824 in the initial uncapped run) and reached the target secret file before
the cap. Later normal-budget trials confirmed stable execution and complete
Harbor artifacts, but did not pass the task: a 24-turn run used exact requested
placeholders but excluded a discovered JSON file; a 32-turn profile still
searched without editing. These are useful evidence for improving terminal
task policy, not a claim of benchmark quality. Fair strategy comparisons need
the same normal turn budget and multiple trials.

### From Dict / YAML / JSON

```python
from nonoka import Agent

# From dict
agent = Agent.from_dict({
    "model": "gpt-4o",
    "tools": ["my_tools:get_weather"],
})

# From file
agent = Agent.from_yaml("agent.yaml")
agent = Agent.from_json("agent.json")
```

### Environment-driven Settings

Nonoka also integrates with `pydantic-settings` for framework-level config:

```python
from nonoka.core.config import settings

print(settings.default_model)   # from NONOKA_DEFAULT_MODEL env var
print(settings.openai_api_key)  # from NONOKA_OPENAI_API_KEY env var
```

## Requirements

- Python >= 3.10

## License

MIT
