A production-grade, type-safe Python Agent framework
Project description
Nonoka
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, andPlanExecutorparadigms out of the box - First-class tools —
@tooldecorator with automatic Pydantic schema generation - Prompt engineering —
@promptdecorator andPromptTemplatefor 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_skilltool - External capabilities — delegate tool execution to a host/frontend (e.g. OpenCode) using
ExternalCapabilityandresume_external_tools() - Resilient execution — structured error taxonomy (
TransientError,LogicError,SafetyError, etc.) with configurableRetryPolicy - Observable hooks —
Hookssystem for tracing, logging, and custom middleware - Multi-backend LLM — powered by
litellm, supporting OpenAI, Anthropic, DeepSeek, and 100+ providers
Installation
pip install nonoka
Or with uv:
uv add nonoka
Quick Start
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:
Agentis a pure configuration object. Execution is handled byRunner, 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:
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:
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
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:
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.",
)
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.
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:
# 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-chat
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-chat
max_turns: 10
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):
agent:
model: gpt-4o
system_prompt: "You are helpful."
agent = config.agent.build()
Environment Variables in Config
Use ${VAR} or ${VAR:-default} in YAML values:
agent:
model: ${NONOKA_MODEL:-gpt-4o}
system_prompt: ${NONOKA_PROMPT}
Fluent Builder API
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():
agent = AgentBuilder().model("gpt-4o").tools(registry).build()
Skills
Apply pre-packaged skills directly in the builder:
from nonoka import AgentBuilder, Skill
skill = Skill.from_file("skills/code-review.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:
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()
)
The load_skill tool injects the selected skill's system_prompt and activation_prompt into the conversation as a system message.
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:
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().
from nonoka import AgentBuilder, Runner, ExternalCapability
cap = ExternalCapability(
name="bash",
description="Run a shell command.",
parameters={
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
)
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.
async for event in runner.resume_external_tools(
agent,
deps=None,
session_id="session-123",
results={"call_abc": "Hello, world!"},
):
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.
From Dict / YAML / JSON
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:
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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nonoka-1.3.4.tar.gz.
File metadata
- Download URL: nonoka-1.3.4.tar.gz
- Upload date:
- Size: 163.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8509d55c7d6caa9d206db6d8be6f66ed857a69546d513322929ebc9bd5fe14a1
|
|
| MD5 |
1ec1bee0a195e6959794019f2419014b
|
|
| BLAKE2b-256 |
918db824004bb8adc8c09d1029bb88daa610a02ce3ee386702ee25d782d55104
|
File details
Details for the file nonoka-1.3.4-py3-none-any.whl.
File metadata
- Download URL: nonoka-1.3.4-py3-none-any.whl
- Upload date:
- Size: 127.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d09ee5ac87cc8ea5635232eb7d1378ce757c3ec11a5753185c884b096ad0ce35
|
|
| MD5 |
2e0ec4f28545fa8570e1ebd6a7d4d6da
|
|
| BLAKE2b-256 |
25910f4c0437d31d2a58b4f35bb2ad8cb99d6af7e8475f1ba68db1911b7b7821
|