pynanoagent 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nanoagent/__init__.py +24 -0
- nanoagent/api.py +93 -0
- nanoagent/cli/__init__.py +1 -0
- nanoagent/cli/main.py +65 -0
- nanoagent/core/__init__.py +48 -0
- nanoagent/core/context.py +58 -0
- nanoagent/core/errors.py +14 -0
- nanoagent/core/hooks.py +66 -0
- nanoagent/core/loop.py +111 -0
- nanoagent/core/message.py +46 -0
- nanoagent/core/protocols.py +78 -0
- nanoagent/core/stop.py +17 -0
- nanoagent/llm/__init__.py +13 -0
- nanoagent/llm/echo.py +24 -0
- nanoagent/llm/openai_compat.py +110 -0
- nanoagent/memory/__init__.py +9 -0
- nanoagent/memory/backend.py +25 -0
- nanoagent/strategies/__init__.py +14 -0
- nanoagent/strategies/context.py +25 -0
- nanoagent/strategies/permission.py +24 -0
- nanoagent/strategies/stop.py +18 -0
- nanoagent/tools/__init__.py +11 -0
- nanoagent/tools/builtin.py +50 -0
- nanoagent/tools/decorator.py +65 -0
- nanoagent/tools/registry.py +32 -0
- nanoagent/tools/schema.py +74 -0
- pynanoagent-0.1.0.dev0.dist-info/METADATA +149 -0
- pynanoagent-0.1.0.dev0.dist-info/RECORD +31 -0
- pynanoagent-0.1.0.dev0.dist-info/WHEEL +4 -0
- pynanoagent-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- pynanoagent-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""OpenAICompatClient —— 包官方 openai SDK,在 core Message 与 OpenAI message dict 间双向翻译。
|
|
2
|
+
|
|
3
|
+
兼容任何 OpenAI 风格接口(DeepSeek / Kimi / vLLM / Ollama 等),由 base_url 区分。
|
|
4
|
+
`resolve_model`(读 API key、选 endpoint)在 api 层,不在这里——本客户端只收已解析的 model 名与可选 base_url。
|
|
5
|
+
|
|
6
|
+
两个易踩的坑(v0.1-design §5.1):
|
|
7
|
+
① tool_calls 的唯一权威源是 assistant 消息自带的 tool_calls;
|
|
8
|
+
② tool 消息必须带 tool_call_id 且与前一条 assistant 的某个 tool_calls[].id 配对,否则下一轮 400。
|
|
9
|
+
|
|
10
|
+
`_to_openai` / `_parse_response` 是模块级纯函数:不触发 openai import,可脱离网络逐 case 单测。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from nanoagent.core import LLMResponse, Message, ToolCall
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _safe(text):
|
|
20
|
+
"""去掉无法编码的孤立代理项(lone surrogate,如非 UTF-8 locale 下 input() 读坏的中文),
|
|
21
|
+
避免 openai 请求 json 编码时抛 UnicodeEncodeError。干净字符串原样返回。"""
|
|
22
|
+
if not isinstance(text, str):
|
|
23
|
+
return text
|
|
24
|
+
return text.encode("utf-8", "replace").decode("utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_openai(m: Message) -> dict:
|
|
28
|
+
"""core Message → OpenAI message dict(内容统一过 _safe 净化)。"""
|
|
29
|
+
if m.role == "tool":
|
|
30
|
+
# tool 结果消息:必须带 tool_call_id 与前一条 assistant 的调用配对
|
|
31
|
+
tr = m.tool_result
|
|
32
|
+
return {
|
|
33
|
+
"role": "tool",
|
|
34
|
+
"tool_call_id": tr.call_id if tr else "",
|
|
35
|
+
"content": _safe(tr.content) if tr else "",
|
|
36
|
+
}
|
|
37
|
+
if m.role == "assistant" and m.tool_calls:
|
|
38
|
+
# 带工具调用的 assistant:arguments 序列化成 JSON 字符串
|
|
39
|
+
return {
|
|
40
|
+
"role": "assistant",
|
|
41
|
+
"content": _safe(m.content) or None,
|
|
42
|
+
"tool_calls": [
|
|
43
|
+
{
|
|
44
|
+
"id": c.id,
|
|
45
|
+
"type": "function",
|
|
46
|
+
"function": {
|
|
47
|
+
"name": c.name,
|
|
48
|
+
"arguments": _safe(json.dumps(c.arguments, ensure_ascii=False)),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
for c in m.tool_calls
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
# system / user / 纯文本 assistant
|
|
55
|
+
return {"role": m.role, "content": _safe(m.content)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_response(resp) -> LLMResponse:
|
|
59
|
+
"""OpenAI 响应对象 → LLMResponse。tool_calls 只取 assistant 消息自带的那份。"""
|
|
60
|
+
choice = resp.choices[0].message
|
|
61
|
+
msg = Message(
|
|
62
|
+
role="assistant",
|
|
63
|
+
content=choice.content or "",
|
|
64
|
+
tool_calls=[
|
|
65
|
+
ToolCall(c.id, c.function.name, json.loads(c.function.arguments or "{}"))
|
|
66
|
+
for c in (choice.tool_calls or [])
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
u = getattr(resp, "usage", None)
|
|
70
|
+
usage = (
|
|
71
|
+
{
|
|
72
|
+
"prompt_tokens": u.prompt_tokens,
|
|
73
|
+
"completion_tokens": u.completion_tokens,
|
|
74
|
+
"total_tokens": u.total_tokens,
|
|
75
|
+
}
|
|
76
|
+
if u
|
|
77
|
+
else {}
|
|
78
|
+
)
|
|
79
|
+
return LLMResponse(message=msg, usage=usage)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OpenAICompatClient:
|
|
83
|
+
"""实现 LLMClient 协议(结构化,无需继承)。openai SDK 在此懒加载。
|
|
84
|
+
|
|
85
|
+
兼容任何 OpenAI 风格接口,换 model + base_url 即可。例 :
|
|
86
|
+
- OpenAI :OpenAICompatClient("gpt-4o-mini")
|
|
87
|
+
- **DeepSeek** :OpenAICompatClient("deepseek-chat", base_url="https://api.deepseek.com")
|
|
88
|
+
- Kimi / vLLM / Ollama 同理(各自的 base_url)。
|
|
89
|
+
api_key 缺省由 SDK 读 OPENAI_API_KEY;CLI 还支持用 OPENAI_BASE_URL 环境变量切到 DeepSeek 等端点。
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, model: str, base_url: str | None = None, api_key: str | None = None):
|
|
93
|
+
from openai import OpenAI # 重依赖懒加载:仅真正用真实客户端时才需要 openai
|
|
94
|
+
|
|
95
|
+
self.model = model
|
|
96
|
+
opts: dict = {}
|
|
97
|
+
if base_url:
|
|
98
|
+
opts["base_url"] = base_url
|
|
99
|
+
if api_key:
|
|
100
|
+
opts["api_key"] = api_key
|
|
101
|
+
self._client = OpenAI(**opts) # api_key 缺省时由 SDK 读 OPENAI_API_KEY
|
|
102
|
+
|
|
103
|
+
def chat(self, messages, tools=None, **kwargs) -> LLMResponse:
|
|
104
|
+
resp = self._client.chat.completions.create(
|
|
105
|
+
model=self.model,
|
|
106
|
+
messages=[_to_openai(m) for m in messages],
|
|
107
|
+
tools=[t.schema for t in tools] if tools else None,
|
|
108
|
+
**kwargs,
|
|
109
|
+
)
|
|
110
|
+
return _parse_response(resp)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""memory/ —— MemoryBackend 协议的实现。
|
|
2
|
+
|
|
3
|
+
v0.1 只做 working memory,且 working memory 的真身其实是 Context.messages(对话历史);
|
|
4
|
+
InMemoryBackend 是 MemoryBackend 契约的最小实现 + 占位,给 v0.2 文件系统 / episodic memory 留接口形状。
|
|
5
|
+
依赖方向:memory 实现 core 的 MemoryBackend 协议(结构化,无需 import core)。
|
|
6
|
+
"""
|
|
7
|
+
from nanoagent.memory.backend import InMemoryBackend
|
|
8
|
+
|
|
9
|
+
__all__ = ["InMemoryBackend"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""InMemoryBackend —— 进程内 dict 实现,v0.1 working memory 的契约占位。
|
|
2
|
+
|
|
3
|
+
retrieve 在 v0.1 不做向量 / 语义检索(那是 semantic memory,v0.2+ 基于向量),
|
|
4
|
+
只朴素返回最近写入的 k 个值——契约对、实现简陋,符合「形状定对、实现可最简」。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryBackend:
|
|
12
|
+
"""实现 MemoryBackend 协议(store / retrieve / delete),结构化、无需继承。"""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._store: dict[str, Any] = {}
|
|
16
|
+
|
|
17
|
+
def store(self, key: str, value: Any, metadata: dict | None = None) -> None:
|
|
18
|
+
self._store[key] = value
|
|
19
|
+
|
|
20
|
+
def retrieve(self, query: str, k: int = 5) -> list[Any]:
|
|
21
|
+
# v0.1 无语义检索:返回最近写入的 k 个值
|
|
22
|
+
return list(self._store.values())[-k:]
|
|
23
|
+
|
|
24
|
+
def delete(self, key: str) -> None:
|
|
25
|
+
self._store.pop(key, None)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""strategies/ —— core 三个策略 Protocol 的默认实现 + 把策略包成 Hook 的包装器。
|
|
2
|
+
|
|
3
|
+
三个默认实现都极简:v0.1 默认配置「不带 hook、纯 ReAct」就靠它们退化。
|
|
4
|
+
接线方式不同(DESIGN §5.4):
|
|
5
|
+
- ContextStrategy / PermissionStrategy 要被一个 Hook「包」起来才生效
|
|
6
|
+
(ContextHook 在 before_model、PermissionHook 在 before_tool 调用);
|
|
7
|
+
- 停止策略(公开可配的 MaxTurnsStop)是 AgentLoop 的构造参数,不算 hook。
|
|
8
|
+
依赖方向:strategies 依赖 core(实现策略 Protocol、继承 BaseHook),core 不反向依赖。
|
|
9
|
+
"""
|
|
10
|
+
from nanoagent.strategies.context import ContextHook, NoopContext
|
|
11
|
+
from nanoagent.strategies.permission import AllowAll, PermissionHook
|
|
12
|
+
from nanoagent.strategies.stop import MaxTurnsStop
|
|
13
|
+
|
|
14
|
+
__all__ = ["NoopContext", "ContextHook", "AllowAll", "PermissionHook", "MaxTurnsStop"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""上下文策略:默认 NoopContext(不裁剪)+ 把任意 ContextStrategy 包成 Hook 的 ContextHook。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from nanoagent.core import BaseHook
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NoopContext:
|
|
8
|
+
"""默认上下文策略:不裁剪,view() 恒等于全量历史。"""
|
|
9
|
+
|
|
10
|
+
def reduce(self, messages, budget_tokens):
|
|
11
|
+
return messages
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContextHook(BaseHook):
|
|
15
|
+
"""把一个 ContextStrategy「包」成 Hook:在 before_model 调 reduce、写投影(不碰日志)。
|
|
16
|
+
|
|
17
|
+
v0.1 默认不启用(保持纯 ReAct);显式 Agent(..., hooks=[ContextHook(strategy)]) 才介入。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, strategy, budget_tokens: int = 8000):
|
|
21
|
+
self._s = strategy
|
|
22
|
+
self._budget = budget_tokens
|
|
23
|
+
|
|
24
|
+
def before_model(self, ctx) -> None:
|
|
25
|
+
ctx.set_view(self._s.reduce(ctx.messages, self._budget)) # 只写 view 投影,绝不改 messages
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""权限策略:默认 AllowAll(一律放行)+ 把任意 PermissionStrategy 包成 Hook 的 PermissionHook。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from nanoagent.core import BaseHook, ToolDecision
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AllowAll:
|
|
8
|
+
"""默认权限策略:一律放行。"""
|
|
9
|
+
|
|
10
|
+
def check(self, ctx, call) -> ToolDecision:
|
|
11
|
+
return ToolDecision(allowed=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PermissionHook(BaseHook):
|
|
15
|
+
"""把一个 PermissionStrategy「包」成 Hook:在 before_tool 调 check、返回 ToolDecision。
|
|
16
|
+
|
|
17
|
+
返回 allowed=False 是软拒绝:loop 只对该次调用回填 denial 消息、让模型换路,不终止整轮。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, strategy):
|
|
21
|
+
self._s = strategy
|
|
22
|
+
|
|
23
|
+
def before_tool(self, ctx, call) -> ToolDecision:
|
|
24
|
+
return self._s.check(ctx, call)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""停止策略:公开可配的 MaxTurnsStop(只看轮数)。
|
|
2
|
+
|
|
3
|
+
注意:core/loop.py 为不依赖 strategies(§8.1),自带一个等价的私有默认停止 `_MaxTurns`;
|
|
4
|
+
本类是公开、可显式传入 `AgentLoop(stop=MaxTurnsStop(n))` 或被 CompositeStop 组合的版本。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from nanoagent.core import StopReason
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MaxTurnsStop:
|
|
12
|
+
"""实现 StopStrategy 协议(结构化,无需继承)。达 max_turns 即停。"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, max_turns: int = 20):
|
|
15
|
+
self.max_turns = max_turns
|
|
16
|
+
|
|
17
|
+
def should_stop(self, ctx, turn: int):
|
|
18
|
+
return StopReason.MAX_TURNS if turn >= self.max_turns else None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Tool 系统:@tool 装饰器、注册表、签名→OpenAI schema 生成、内置工具。
|
|
2
|
+
|
|
3
|
+
依赖方向:tools 依赖 core(实现 Tool 协议),core 不反向依赖 tools。
|
|
4
|
+
内置工具在 ``nanoagent.tools.builtin``(按需 import,避免无谓拉起第三方依赖)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from nanoagent.tools.decorator import FunctionTool, tool
|
|
8
|
+
from nanoagent.tools.registry import ToolRegistry
|
|
9
|
+
from nanoagent.tools.schema import build_schema
|
|
10
|
+
|
|
11
|
+
__all__ = ["tool", "FunctionTool", "ToolRegistry", "build_schema"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nanoagent.tools.decorator import tool
|
|
4
|
+
|
|
5
|
+
# 第三方/重依赖在函数内部延迟 import,使「import builtin」本身零依赖、可在无网络/未装包时加载。
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@tool
|
|
9
|
+
def read_file(path: str) -> str:
|
|
10
|
+
"""读取文本文件内容。"""
|
|
11
|
+
with open(path, encoding="utf-8") as f:
|
|
12
|
+
return f.read()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@tool
|
|
16
|
+
def write_file(path: str, content: str) -> str:
|
|
17
|
+
"""把内容写入文本文件,返回确认信息。"""
|
|
18
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
19
|
+
f.write(content)
|
|
20
|
+
return f"已写入 {path}({len(content)} 字符)"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@tool
|
|
24
|
+
def list_files(directory: str, pattern: str = "*") -> list:
|
|
25
|
+
"""列出目录下匹配 glob 模式的文件路径。"""
|
|
26
|
+
import glob
|
|
27
|
+
import os
|
|
28
|
+
|
|
29
|
+
return sorted(glob.glob(os.path.join(directory, pattern)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@tool
|
|
33
|
+
def run_shell(command: str) -> str:
|
|
34
|
+
"""执行 shell 命令,返回 stdout+stderr(v0.1 无沙箱,危险性靠 v0.3 权限策略约束)。"""
|
|
35
|
+
import subprocess
|
|
36
|
+
|
|
37
|
+
proc = subprocess.run(command, shell=True, capture_output=True, text=True)
|
|
38
|
+
return (proc.stdout + proc.stderr).strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@tool
|
|
42
|
+
def web_search(query: str, k: int = 5) -> list:
|
|
43
|
+
"""用 DuckDuckGo 检索,返回前 k 条「标题 — 链接」。"""
|
|
44
|
+
from duckduckgo_search import DDGS
|
|
45
|
+
|
|
46
|
+
with DDGS() as ddgs:
|
|
47
|
+
return [f"{r['title']} — {r['href']}" for r in ddgs.text(query, max_results=k)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
BUILTIN_TOOLS = [read_file, write_file, list_files, run_shell, web_search]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from nanoagent.tools.schema import build_schema
|
|
9
|
+
|
|
10
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
11
|
+
_MAX_NAME = 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_name(name: str) -> None:
|
|
15
|
+
"""工具名规则:^[a-z][a-z0-9_]*$ 且 ≤64 字符(DESIGN §14.3)。"""
|
|
16
|
+
if not _NAME_RE.match(name):
|
|
17
|
+
raise ValueError(f"工具名非法(须匹配 ^[a-z][a-z0-9_]*$): {name!r}")
|
|
18
|
+
if len(name) > _MAX_NAME:
|
|
19
|
+
raise ValueError(f"工具名超过 {_MAX_NAME} 字符: {name!r}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _first_doc_line(fn: Callable) -> str:
|
|
23
|
+
doc = inspect.getdoc(fn)
|
|
24
|
+
if not doc:
|
|
25
|
+
return ""
|
|
26
|
+
for line in doc.splitlines():
|
|
27
|
+
stripped = line.strip()
|
|
28
|
+
if stripped:
|
|
29
|
+
return stripped
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class FunctionTool:
|
|
35
|
+
"""`@tool` 的产物:满足 core.Tool 协议(name / description / schema + __call__)。"""
|
|
36
|
+
|
|
37
|
+
fn: Callable[..., Any]
|
|
38
|
+
name: str
|
|
39
|
+
description: str
|
|
40
|
+
schema: dict
|
|
41
|
+
|
|
42
|
+
def __call__(self, **kwargs: Any) -> Any:
|
|
43
|
+
return self.fn(**kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tool(
|
|
47
|
+
fn: Callable | None = None,
|
|
48
|
+
*,
|
|
49
|
+
name: str | None = None,
|
|
50
|
+
description: str | None = None,
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""把普通函数注册为工具。`@tool` 或 `@tool(name=..., description=...)` 均可。
|
|
53
|
+
|
|
54
|
+
名字默认取函数名(强制 ^[a-z][a-z0-9_]*$ 且 ≤64);描述默认取 docstring 首行;
|
|
55
|
+
schema 由函数签名 + 类型注解自动生成(见 tools/schema.py)。重名校验在注册表(registry)做。
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def wrap(f: Callable) -> FunctionTool:
|
|
59
|
+
t_name = name or f.__name__
|
|
60
|
+
_validate_name(t_name)
|
|
61
|
+
desc = description or _first_doc_line(f)
|
|
62
|
+
return FunctionTool(fn=f, name=t_name, description=desc,
|
|
63
|
+
schema=build_schema(f, t_name, desc))
|
|
64
|
+
|
|
65
|
+
return wrap(fn) if fn is not None else wrap
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nanoagent.core import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolRegistry:
|
|
7
|
+
"""name -> Tool 的注册表;register 时重名报错(v0.1 不引 namespace 前缀,DESIGN §14.3)。"""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._tools: dict[str, Tool] = {}
|
|
11
|
+
|
|
12
|
+
def register(self, t: Tool) -> Tool:
|
|
13
|
+
if t.name in self._tools:
|
|
14
|
+
raise ValueError(f"工具重名: {t.name!r}")
|
|
15
|
+
self._tools[t.name] = t
|
|
16
|
+
return t
|
|
17
|
+
|
|
18
|
+
def register_all(self, tools) -> None:
|
|
19
|
+
for t in tools:
|
|
20
|
+
self.register(t)
|
|
21
|
+
|
|
22
|
+
def get(self, name: str):
|
|
23
|
+
return self._tools.get(name)
|
|
24
|
+
|
|
25
|
+
def all(self) -> list:
|
|
26
|
+
return list(self._tools.values())
|
|
27
|
+
|
|
28
|
+
def __contains__(self, name: str) -> bool:
|
|
29
|
+
return name in self._tools
|
|
30
|
+
|
|
31
|
+
def __len__(self) -> int:
|
|
32
|
+
return len(self._tools)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import types
|
|
5
|
+
import typing
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# Python 标量类型 → JSON Schema type(v0.1 支持范围,见 v0.1-design.md §4.2 映射表)
|
|
9
|
+
_JSON_PRIMITIVES = {str: "string", int: "integer", float: "number", bool: "boolean"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _unwrap_optional(tp: Any) -> tuple[Any, bool]:
|
|
13
|
+
"""Optional[X] / Union[X, None] / X | None -> (inner, is_optional)。
|
|
14
|
+
|
|
15
|
+
仅在「恰好一个非 None 分支」时解出 inner;其余 Union 原样返回(v0.1 不深挖多分支 Union)。
|
|
16
|
+
"""
|
|
17
|
+
origin = typing.get_origin(tp)
|
|
18
|
+
union_type = getattr(types, "UnionType", None) # PEP 604(3.10+)的 X | Y
|
|
19
|
+
if origin is typing.Union or (union_type is not None and origin is union_type):
|
|
20
|
+
args = typing.get_args(tp)
|
|
21
|
+
non_none = [a for a in args if a is not type(None)]
|
|
22
|
+
is_optional = len(non_none) < len(args)
|
|
23
|
+
if len(non_none) == 1:
|
|
24
|
+
return non_none[0], is_optional
|
|
25
|
+
return tp, is_optional
|
|
26
|
+
return tp, False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _json_for(tp: Any) -> dict:
|
|
30
|
+
"""把一个(已解包 Optional 的)Python 类型映射成 JSON Schema 片段。"""
|
|
31
|
+
if tp in _JSON_PRIMITIVES:
|
|
32
|
+
return {"type": _JSON_PRIMITIVES[tp]}
|
|
33
|
+
origin = typing.get_origin(tp) or tp
|
|
34
|
+
if origin in (list, set, frozenset, tuple):
|
|
35
|
+
return {"type": "array"} # v0.1 不展开 items(见 §4.2 优化空间)
|
|
36
|
+
if origin is dict:
|
|
37
|
+
return {"type": "object"}
|
|
38
|
+
return {"type": "string"} # 兜底:未识别 / 无注解按 string
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_schema(fn: Any, name: str, description: str) -> dict:
|
|
42
|
+
"""从函数签名 + 类型注解生成 OpenAI Function Calling schema。
|
|
43
|
+
|
|
44
|
+
无默认值且非 Optional 的参数进 required;类型注解无法解析时整体退化为无类型(全 string)。
|
|
45
|
+
"""
|
|
46
|
+
sig = inspect.signature(fn)
|
|
47
|
+
try:
|
|
48
|
+
hints = typing.get_type_hints(fn)
|
|
49
|
+
except Exception:
|
|
50
|
+
hints = {}
|
|
51
|
+
properties: dict[str, dict] = {}
|
|
52
|
+
required: list[str] = []
|
|
53
|
+
for pname, param in sig.parameters.items():
|
|
54
|
+
if pname == "self":
|
|
55
|
+
continue
|
|
56
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
57
|
+
continue
|
|
58
|
+
tp = hints.get(pname)
|
|
59
|
+
if tp is None:
|
|
60
|
+
prop, is_optional = {"type": "string"}, False
|
|
61
|
+
else:
|
|
62
|
+
inner, is_optional = _unwrap_optional(tp)
|
|
63
|
+
prop = _json_for(inner)
|
|
64
|
+
properties[pname] = prop
|
|
65
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
66
|
+
if not has_default and not is_optional:
|
|
67
|
+
required.append(pname)
|
|
68
|
+
parameters: dict = {"type": "object", "properties": properties}
|
|
69
|
+
if required:
|
|
70
|
+
parameters["required"] = required
|
|
71
|
+
return {
|
|
72
|
+
"type": "function",
|
|
73
|
+
"function": {"name": name, "description": description, "parameters": parameters},
|
|
74
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pynanoagent
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: 易于理解、可融入最佳实践、且真正可用的 ReAct 单 agent Python 框架(核心循环 ~30 行)
|
|
5
|
+
Project-URL: Homepage, https://github.com/eastonsuo/nanoagent
|
|
6
|
+
Project-URL: Repository, https://github.com/eastonsuo/nanoagent
|
|
7
|
+
Author: eastonsuo
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,deepseek,llm,openai,react,tool-calling
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Requires-Dist: duckduckgo-search>=6
|
|
13
|
+
Requires-Dist: openai>=1.30
|
|
14
|
+
Requires-Dist: prompt-toolkit>=3
|
|
15
|
+
Requires-Dist: rich>=13
|
|
16
|
+
Requires-Dist: tiktoken>=0.7
|
|
17
|
+
Provides-Extra: anthropic
|
|
18
|
+
Requires-Dist: anthropic>=0.30; extra == 'anthropic'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: import-linter>=2; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Provides-Extra: trace
|
|
24
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'trace'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# nanoagent
|
|
28
|
+
|
|
29
|
+
> 一个核心循环只有约 30 行、却能通过分阶段引入 harness 工程实践演进为真实可用运行时的 **ReAct 单 agent 框架**。既把 agent 的内部机制讲清楚,也让人能基于它构建自己的 agent 应用。
|
|
30
|
+
|
|
31
|
+
> **状态:v0.1 基础版已实现**。核心循环 / 工具系统 / LLM 客户端(OpenAI 兼容,含 DeepSeek)/ in-memory memory / 默认策略 / 命令行入口均已落地,`python -m pytest` 61 passed(全程不联网)。**当前可从源码安装运行**(见[快速开始](#快速开始));PyPI 发布在即,届时 `pip install pynanoagent`(发布名加 `py` 前缀,导入仍 `import nanoagent`)。设计见 [`docs/DESIGN.md`](docs/DESIGN.md)。
|
|
32
|
+
|
|
33
|
+
## 这是什么
|
|
34
|
+
|
|
35
|
+
2026 年的 agent 框架生态有一个结构性空白:**编排类框架**(LangGraph / CrewAI)能让 agent 跑起来,但 context 管理、权限、可观测性往往是后补的,复杂且边界不清;**产品级 agent**(Claude Code / Cursor)harness 成熟却闭源、绑定模型、不可复用。「能让人读懂每一层为什么这样设计、同时又真正可用」的开源框架几乎是空白。
|
|
36
|
+
|
|
37
|
+
nanoagent 要填的就是这个空白,三个关键词缺一不可:
|
|
38
|
+
|
|
39
|
+
- **易于理解**:核心层代码量小、抽象少,能在一两小时读完 `core/loop.py` 并真正理解每一轮在做什么。
|
|
40
|
+
- **融入最佳实践**:context engineering、权限校验、熔断、memory 分层、skill 系统等社区已验证的工程实践,分阶段、有解释地引入。
|
|
41
|
+
- **真正可用**:不停在 toy 阶段——目标是 v0.4 能支撑长任务而不崩溃。
|
|
42
|
+
|
|
43
|
+
## 核心设计原则:Stable Core + Pluggable Strategy
|
|
44
|
+
|
|
45
|
+
整个项目押在一个切分上:把框架切成**核心层**(过去五年没本质变化、未来数年大概率也不变的部分——LLM 调用、循环、工具调度、数据结构)与**策略层**(随最佳实践演化的部分——怎么管上下文、怎么校验权限、怎么熔断)。核心层一旦定稿就不再改动;引入新最佳实践 = 在策略层新增一个实现,核心层不动。
|
|
46
|
+
|
|
47
|
+
下图回答:「nanoagent 由哪几层组成、运行时怎样互相调用?」
|
|
48
|
+
|
|
49
|
+
```mermaid
|
|
50
|
+
flowchart TD
|
|
51
|
+
user["使用者前端<br/>CLI / bot / 编辑器插件"]
|
|
52
|
+
api["顶层 API<br/>Agent / ChatSession"]
|
|
53
|
+
|
|
54
|
+
subgraph core["核心层 · 数年不变"]
|
|
55
|
+
loop["AgentLoop<br/>~30 行 ReAct 循环"]
|
|
56
|
+
data["数据结构 + 契约<br/>Message / Context / Protocol"]
|
|
57
|
+
hooks["Hook 机制<br/>8 个生命周期点"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
subgraph ext["可替换扩展点"]
|
|
61
|
+
caps["能力实现<br/>LLM / 工具 / Memory"]
|
|
62
|
+
strat["策略层<br/>上下文 / 权限 / 停止"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
user -->|"调用"| api
|
|
66
|
+
api -->|"组装并运行"| loop
|
|
67
|
+
loop -->|"读写"| data
|
|
68
|
+
loop -->|"直接调用"| caps
|
|
69
|
+
loop -->|"生命周期回调"| hooks
|
|
70
|
+
hooks -.->|"被策略实现并注入"| strat
|
|
71
|
+
|
|
72
|
+
classDef stable fill:#fff0aa
|
|
73
|
+
class loop,data,hooks stable
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**读图要点**:暖黄三块是核心层,数年内不动。注意两种依赖方向的不对称——`AgentLoop` **直接调用**能力实现(LLM / 工具 / Memory 是循环跑起来的必需品,实线),却只**经 Hook 间接触达**策略层(虚线:策略实现某个 Hook 协议、被注入循环的某个生命周期点)。判据很简单:拿掉能力实现层循环就跑不起来,拿掉策略层循环仍能跑(退化成纯 ReAct)。核心层因此既不知道什么是 compaction、也不知道什么是 permission,它只知道「在某个时间点回调一组 hook」——这就是「核心稳定、策略可插拔」能成立的物理机制。
|
|
77
|
+
|
|
78
|
+
## 路线图
|
|
79
|
+
|
|
80
|
+
| 版本 | 范围 | 状态 |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| **v0.1 · Core** | 单 agent + 工具系统 + in-memory memory + 命令行 demo | ✅ 基础版完成(可源码运行,61 单测通过) |
|
|
83
|
+
| v0.2 · Skills + Trace + MCP | Skill 渐进式加载 + OpenTelemetry trace + 文件系统 memory + MCP 工具适配器 | 📋 计划 |
|
|
84
|
+
| v0.3 · Harness | 上下文管理多策略 + 权限系统 + 熔断器 + subagent | 📋 计划 |
|
|
85
|
+
| v0.4 · Eval | 三维度评估框架(独立 repo) | 📋 计划 |
|
|
86
|
+
|
|
87
|
+
## 快速开始
|
|
88
|
+
|
|
89
|
+
当前从源码安装(PyPI 发布后可直接 `pip install pynanoagent`,导入名仍 `nanoagent`):
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
git clone https://github.com/eastonsuo/nanoagent && cd nanoagent
|
|
93
|
+
pip install -e .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**命令行对话**(需一个 OpenAI 兼容的 key):
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
export OPENAI_API_KEY=sk-... # OpenAI
|
|
100
|
+
nanoagent
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
换 **DeepSeek** 等兼容端点:模型名带前缀即自动识别端点,只需给对应 key(无需手设 base_url):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
export DEEPSEEK_API_KEY=sk-... # DeepSeek(没有则回落 OPENAI_API_KEY)
|
|
107
|
+
export NANOAGENT_MODEL=deepseek-chat
|
|
108
|
+
nanoagent
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**当库用** —— `@tool` 注册工具,一次性任务用 `Agent.run`、多轮对话用 `Agent(...).session()`:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from nanoagent import Agent, tool
|
|
115
|
+
|
|
116
|
+
@tool
|
|
117
|
+
def word_count(path: str) -> int:
|
|
118
|
+
"""统计文本文件的单词数。"""
|
|
119
|
+
return len(open(path).read().split())
|
|
120
|
+
|
|
121
|
+
agent = Agent("gpt-4o-mini", tools=[word_count])
|
|
122
|
+
print(agent.run("统计 README.md 有多少单词").output) # 一次性,跨 run 无记忆
|
|
123
|
+
|
|
124
|
+
chat = agent.session() # 多轮,记得上文
|
|
125
|
+
chat.send("我叫小明")
|
|
126
|
+
print(chat.send("我叫什么?").output)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## v0.1 已实现
|
|
130
|
+
|
|
131
|
+
| 层 | 模块 | 内容 |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| 核心层 · 数年不变 | `core/` | 数据结构 + 能力/策略契约 + 8 点 Hook + `AgentLoop`(~30 行 ReAct 循环)|
|
|
134
|
+
| 能力实现 | `tools/` `llm/` `memory/` | `@tool` + schema 自动生成;OpenAI 兼容客户端(+ 测试用 echo);in-memory memory |
|
|
135
|
+
| 策略层 · 可插拔 | `strategies/` | 默认 noop / allow-all / max-turns + 把策略包成 Hook |
|
|
136
|
+
| 入口装配 | `api.py` `cli/` | `Agent` / `ChatSession` + 命令行 REPL |
|
|
137
|
+
|
|
138
|
+
- **可读性**:核心层(非 `__init__`)约 390 行,< 500。
|
|
139
|
+
- **测试**:`python -m pytest` → 61 passed,全程不联网、不需 API key(echo 客户端驱动全链路)。
|
|
140
|
+
- **依赖防线**:`core/` 不 import 任何外层目录——「稳定核心」原则的物理保证(设计 §8.1)。
|
|
141
|
+
- **OpenAI 兼容**:OpenAI / DeepSeek / Kimi / vLLM / Ollama,换模型名或端点即可。
|
|
142
|
+
|
|
143
|
+
## 文档
|
|
144
|
+
|
|
145
|
+
- [`docs/DESIGN.md`](docs/DESIGN.md) —— 权威设计文档(14 章):概念与设计哲学、core/harness 解耦、核心数据结构与接口契约、整体架构、v0.1 详细设计、技术选型、与现有框架对比、风险。**实现细节一律以它为准。**
|
|
146
|
+
|
|
147
|
+
## 许可证
|
|
148
|
+
|
|
149
|
+
本项目采用 [MIT License](LICENSE)。
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
nanoagent/__init__.py,sha256=iwNBj4hIzY5rAaJMUI29SvPuDREenqfhCizzfi0JuxI,721
|
|
2
|
+
nanoagent/api.py,sha256=dLJSxMhgXpSyySY8BZXG0DnSOjJ4pc4u-ZZt7HDlGtM,4545
|
|
3
|
+
nanoagent/cli/__init__.py,sha256=zPNEILUKz3CxuaYnyf4vg2E1R9vCkySSZNdIv61LA7k,86
|
|
4
|
+
nanoagent/cli/main.py,sha256=9AVnDw1zzRvJe3vpihGLJNtdXqx8Dh-jdAWI66WkIWE,2615
|
|
5
|
+
nanoagent/core/__init__.py,sha256=5LR9b8kNlZhGHwn0N_eJV6Y7jZj7-NynqKLOChNNpXM,1301
|
|
6
|
+
nanoagent/core/context.py,sha256=Q_mBXGnrYyeHzzVbZuKZt-DJAjCoVVXjWKk3yFPBIXo,2561
|
|
7
|
+
nanoagent/core/errors.py,sha256=T78xuYd8aPVcdknWOGUVBfJmpioDrVO4jw94crQ1QdQ,502
|
|
8
|
+
nanoagent/core/hooks.py,sha256=pbi97Jzfu_BR4VPXZmrbr9iono05SW5k1F9n07wFVic,2586
|
|
9
|
+
nanoagent/core/loop.py,sha256=9RKrE7inmJLccYBbxD8oZfN-wZX4-q6gW6dqNA-Bjww,5387
|
|
10
|
+
nanoagent/core/message.py,sha256=36Z0pDDLNwJ00a-Y1WF_ImvwWdmsqOQh5KurFp5KVAU,1708
|
|
11
|
+
nanoagent/core/protocols.py,sha256=DHIee8Ojb2wNgI0Cd3DYivd2jZikT1ze9-T99G9Umvs,2413
|
|
12
|
+
nanoagent/core/stop.py,sha256=CFCCJcaqk6xECDOWAkKm0X2YxgFIUqVIoKU373OY7gs,605
|
|
13
|
+
nanoagent/llm/__init__.py,sha256=MEs5zE-sGXdlXMyBCzWrYkFXBV5Eb5JEVGdYSTD5YY8,649
|
|
14
|
+
nanoagent/llm/echo.py,sha256=y9z5DhW8ovv4876mzFiCS3mi3CLE7-KfSoih1C359i4,1060
|
|
15
|
+
nanoagent/llm/openai_compat.py,sha256=2FYbNSMR1eB-MKcZ7fy8HzKycPe6kul3CtoX5mhV5FE,4392
|
|
16
|
+
nanoagent/memory/__init__.py,sha256=5ahtbzNLvUw6gG0WjStx96UaWULmdBRRGAAIjkzUKCM,469
|
|
17
|
+
nanoagent/memory/backend.py,sha256=Fpet1SWf-FxlEbWcnA-0rUIM2TkazsgLqmX6P2GfGBo,931
|
|
18
|
+
nanoagent/strategies/__init__.py,sha256=yFDGsE5OsndDjPuqJLfJlfBDAmul3UHpSw3To5W95r0,892
|
|
19
|
+
nanoagent/strategies/context.py,sha256=lihmPrAB8EZQC0xGy9lT0UTV869nCo-PN5pkE8maOG8,899
|
|
20
|
+
nanoagent/strategies/permission.py,sha256=aR8kTxLKrfdn3drXVRIQxJaVB5VIJMK97CXGPrJHtk4,790
|
|
21
|
+
nanoagent/strategies/stop.py,sha256=-a5oJ3g3VNM2vAOhWZV_9esUoKizUUCd59ZdIUCE6QE,681
|
|
22
|
+
nanoagent/tools/__init__.py,sha256=Oh0vdguDdpO8Ng9vRWF7R4DBf0c8Bzn5ss0qmxaXfb8,515
|
|
23
|
+
nanoagent/tools/builtin.py,sha256=f6mmd_6ZIj8N_qHD8cIUY6nMwOZwcJTXvBDfpKbiVjQ,1504
|
|
24
|
+
nanoagent/tools/decorator.py,sha256=CNT7eFUmkEULg6rOfdwJH33WtoV04qIVs3oO6GioTMA,1923
|
|
25
|
+
nanoagent/tools/registry.py,sha256=qdZbaxAYKu74vX9lwkkV2rkneESws2VIyWlS2bfNrDk,845
|
|
26
|
+
nanoagent/tools/schema.py,sha256=K2lZeHTTEH_vv3k9CxRLiTX3Ra_OIxBiiVmZvZW0Zqk,2888
|
|
27
|
+
pynanoagent-0.1.0.dev0.dist-info/METADATA,sha256=BtmGd0VwBF9HtbVQ6s8thXkfX9H-ElYclgaqFDyLZfU,7659
|
|
28
|
+
pynanoagent-0.1.0.dev0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
29
|
+
pynanoagent-0.1.0.dev0.dist-info/entry_points.txt,sha256=HcMrlkgxrt-WpMphpgyfqpb7tvXEAFznb_39VcWakG0,54
|
|
30
|
+
pynanoagent-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=5VL1IPyaZUR2uOprlubLZXyLvOtnUkQWuFpbU7rgzW8,1075
|
|
31
|
+
pynanoagent-0.1.0.dev0.dist-info/RECORD,,
|