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 ADDED
@@ -0,0 +1,24 @@
1
+ """nanoagent —— 一个核心循环 ~30 行的 ReAct 单 agent 框架。
2
+
3
+ 常用入口:
4
+
5
+ from nanoagent import Agent, tool
6
+
7
+ @tool
8
+ def word_count(path: str) -> int:
9
+ '''统计文本文件的单词数。'''
10
+ return len(open(path).read().split())
11
+
12
+ agent = Agent("gpt-4o-mini", tools=[word_count])
13
+ print(agent.run("统计 README.md 有多少单词").output)
14
+
15
+ 一次性任务用 Agent.run;多轮对话用 Agent(...).session().send(见 DESIGN §5.6)。
16
+ 全部核心契约与数据结构在 ``nanoagent.core``。
17
+ """
18
+
19
+ __version__ = "0.1.0.dev0"
20
+
21
+ from nanoagent.api import Agent, ChatSession
22
+ from nanoagent.tools import tool
23
+
24
+ __all__ = ["Agent", "ChatSession", "tool", "__version__"]
nanoagent/api.py ADDED
@@ -0,0 +1,93 @@
1
+ """api.py —— 唯一「知道全部模块」的装配层(不在 core 内,可 import 全部)。
2
+
3
+ 把字符串模型名解析成 LLMClient(core 永远只见解析后的客户端),并提供两个平级入口:
4
+ - Agent :一次性任务,每次 run 新建 Context(跨 run 无记忆、相互隔离);
5
+ - ChatSession :多轮对话,复用同一个 Context(带记忆)。
6
+ 二者共享同一个无状态 AgentLoop——状态归 Context、编排归 Loop(DESIGN §5.6 / §14.3-Q3)。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from nanoagent.core import AgentLoop, AgentResult, Context, Message
13
+
14
+ # 内置默认 system prompt(放装配层、不进 core);Agent(system_prompt=...) 可整体覆盖,传 "" 则不加。
15
+ DEFAULT_SYSTEM_PROMPT = (
16
+ "你是一个能调用工具完成任务的助手。需要外部信息或操作时,调用合适的工具,"
17
+ "不要编造工具的返回结果;信息齐了就用自然语言直接回答。"
18
+ "当你不再需要调用工具、直接给出答复时,本轮任务即视为结束。"
19
+ )
20
+
21
+
22
+ # 按模型名前缀自动选 OpenAI 兼容端点:(前缀, base_url, 专属 api_key 环境变量)。
23
+ # 命中则自动配 base_url、并优先读该供应商的 key(没有则回落到 OpenAI SDK 默认的 OPENAI_API_KEY)。
24
+ _PROVIDERS = [
25
+ ("deepseek", "https://api.deepseek.com", "DEEPSEEK_API_KEY"),
26
+ ("moonshot", "https://api.moonshot.cn/v1", "MOONSHOT_API_KEY"),
27
+ ("kimi", "https://api.moonshot.cn/v1", "MOONSHOT_API_KEY"),
28
+ ]
29
+
30
+
31
+ def _detect_endpoint(model: str):
32
+ """按模型名前缀推断 (base_url, api_key 环境变量名);未命中返回 (None, None)=OpenAI 默认。"""
33
+ for prefix, base_url, key_env in _PROVIDERS:
34
+ if model.startswith(prefix):
35
+ return base_url, key_env
36
+ return None, None
37
+
38
+
39
+ def resolve_model(model):
40
+ """字符串模型名 → LLMClient;已是 LLMClient 则原样返回。
41
+
42
+ 自动选端点:模型名以已知前缀开头(如 ``deepseek-chat``)时,自动配好对应 base_url,
43
+ 只需设供应商 key(DeepSeek 用 DEEPSEEK_API_KEY,没有则回落 OPENAI_API_KEY)即可。
44
+ 显式 ``OPENAI_BASE_URL`` 环境变量优先级最高,可覆盖自动判断、接任意自建/未知端点。
45
+ """
46
+ if not isinstance(model, str):
47
+ return model
48
+ from nanoagent.llm import OpenAICompatClient # 懒加载:仅用真实客户端时才需 openai
49
+
50
+ base_url = os.environ.get("OPENAI_BASE_URL") # 显式覆盖优先
51
+ api_key = None
52
+ if base_url is None:
53
+ base_url, key_env = _detect_endpoint(model) # 按模型名自动选端点
54
+ if key_env:
55
+ api_key = os.environ.get(key_env) # 供应商专属 key;None 时 SDK 回落 OPENAI_API_KEY
56
+ return OpenAICompatClient(model, base_url=base_url, api_key=api_key)
57
+
58
+
59
+ class Agent:
60
+ """一次性任务的入口:每次 run 新建 Context(跨 run 无记忆、相互隔离)。
61
+
62
+ 「无状态」指跨 run;单次 run 内仍是多轮 ReAct。本类只持有配置(loop + system_prompt)。
63
+ """
64
+
65
+ def __init__(self, model, tools=None, *, system_prompt: str | None = None,
66
+ hooks=None, stop=None, max_turns: int = 20):
67
+ self._loop = AgentLoop(resolve_model(model), tools or [], hooks, stop, max_turns)
68
+ # None → 用内置默认 prompt;显式 "" 则不加 system 消息
69
+ self._system = DEFAULT_SYSTEM_PROMPT if system_prompt is None else system_prompt
70
+
71
+ def run(self, prompt: str) -> AgentResult:
72
+ ctx = Context()
73
+ if self._system:
74
+ ctx.add(Message(role="system", content=self._system, pinned=True))
75
+ ctx.add(Message(role="user", content=prompt))
76
+ return self._loop.run(ctx)
77
+
78
+ def session(self) -> "ChatSession": # 便捷工厂:复用同一个 loop 开个有状态会话(非「包含」ChatSession)
79
+ return ChatSession(self._loop, self._system)
80
+
81
+
82
+ class ChatSession:
83
+ """多轮对话入口:与 Agent 平级,复用同一个无状态 AgentLoop + 持续累积的同一个 Context。"""
84
+
85
+ def __init__(self, loop: AgentLoop, system_prompt: str | None = None):
86
+ self._loop = loop
87
+ self.ctx = Context()
88
+ if system_prompt:
89
+ self.ctx.add(Message(role="system", content=system_prompt, pinned=True))
90
+
91
+ def send(self, prompt: str) -> AgentResult:
92
+ self.ctx.add(Message(role="user", content=prompt))
93
+ return self._loop.run(self.ctx) # 原地增长同一个 ctx
@@ -0,0 +1 @@
1
+ """cli/ —— 命令行 REPL。薄封装:重逻辑都在可单测的 api 层。"""
nanoagent/cli/main.py ADDED
@@ -0,0 +1,65 @@
1
+ """命令行入口:`nanoagent` 命令(pyproject [project.scripts] → cli.main:main)。
2
+
3
+ v0.1 基础版用标准库 input() 做 REPL(prompt-toolkit 的补全 / 历史留作后续打磨);
4
+ 底层是一个 ChatSession,整段对话复用同一个 Context(记得上文)。
5
+ 配置:模型读 NANOAGENT_MODEL(默认 gpt-4o-mini);API key 读 OPENAI_API_KEY;
6
+ 切 DeepSeek 等端点用 OPENAI_BASE_URL(如 https://api.deepseek.com)。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from nanoagent import __version__
13
+
14
+
15
+ _PROVIDER_KEYS = ("OPENAI_API_KEY", "DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")
16
+
17
+
18
+ def _default_model() -> str:
19
+ """没显式设 NANOAGENT_MODEL 时,按已设的供应商 key 猜默认模型
20
+ (设了 DEEPSEEK_API_KEY 就用 deepseek-chat,端点会被自动识别,无需再设模型名)。"""
21
+ explicit = os.environ.get("NANOAGENT_MODEL")
22
+ if explicit:
23
+ return explicit
24
+ if os.environ.get("DEEPSEEK_API_KEY"):
25
+ return "deepseek-chat"
26
+ if os.environ.get("MOONSHOT_API_KEY"):
27
+ return "moonshot-v1-8k"
28
+ return "gpt-4o-mini"
29
+
30
+
31
+ def main() -> None:
32
+ from prompt_toolkit import PromptSession
33
+ from nanoagent.api import Agent
34
+ from nanoagent.tools.builtin import BUILTIN_TOOLS
35
+
36
+ model = _default_model()
37
+ if not any(os.environ.get(k) for k in _PROVIDER_KEYS):
38
+ print("⚠️ 未检测到 API key(设 OPENAI_API_KEY / DEEPSEEK_API_KEY 之一即可)。")
39
+
40
+ chat = Agent(model, tools=BUILTIN_TOOLS).session()
41
+ print(f"nanoagent {__version__} · 模型 {model} — 输入问题开始对话(Ctrl-D / Ctrl-C 退出)")
42
+
43
+ # 用 prompt_toolkit 而非 input():方向键 / 行编辑 / 历史,且正确解码 UTF-8 输入
44
+ # (input() 在非 UTF-8 locale 下、或按方向键时会把转义序列/坏字节读进字符串,导致请求编码崩溃)。
45
+ repl = PromptSession()
46
+ while True:
47
+ try:
48
+ prompt = repl.prompt("> ").strip()
49
+ if not prompt:
50
+ continue
51
+ result = chat.send(prompt)
52
+ except (EOFError, KeyboardInterrupt): # Ctrl-D / Ctrl-C(等输入或调模型时都算)→ 干净退出
53
+ print()
54
+ break
55
+ except Exception as e: # 单轮业务出错只提示、不退出整个 REPL
56
+ print(f"⚠️ 出错:{type(e).__name__}: {e}")
57
+ continue
58
+ print(result.output)
59
+ total = result.usage.get("total_tokens")
60
+ if total:
61
+ print(f" ({result.turns} 轮 · {total} tokens)")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
@@ -0,0 +1,48 @@
1
+ """核心层 —— 数年不变的稳定部分。
2
+
3
+ 本包不得 import 任何外层目录(strategies / tools / llm / memory / cli / api):
4
+ 依赖只能从外层指向 core(见 DESIGN §4.1、§8.1,CI 用 import-linter / grep 守)。
5
+ 策略 Protocol(ContextStrategy / PermissionStrategy / StopStrategy)属契约、放本包;
6
+ 它们的实现属 strategies/。
7
+ """
8
+
9
+ from nanoagent.core.message import LLMResponse, Message, ToolCall, ToolResult
10
+ from nanoagent.core.stop import StopReason
11
+ from nanoagent.core.context import AgentResult, Context
12
+ from nanoagent.core.hooks import BaseHook, Hook, ToolDecision
13
+ from nanoagent.core.protocols import (
14
+ ContextStrategy,
15
+ LLMClient,
16
+ MemoryBackend,
17
+ PermissionStrategy,
18
+ StopStrategy,
19
+ Tool,
20
+ )
21
+ from nanoagent.core.errors import FatalError, NanoAgentError
22
+ from nanoagent.core.loop import AgentLoop
23
+
24
+ __all__ = [
25
+ # 数据结构
26
+ "ToolCall",
27
+ "ToolResult",
28
+ "Message",
29
+ "LLMResponse",
30
+ "Context",
31
+ "AgentResult",
32
+ "AgentLoop",
33
+ "StopReason",
34
+ # Hook
35
+ "ToolDecision",
36
+ "Hook",
37
+ "BaseHook",
38
+ # 能力 / 策略契约
39
+ "LLMClient",
40
+ "Tool",
41
+ "MemoryBackend",
42
+ "ContextStrategy",
43
+ "PermissionStrategy",
44
+ "StopStrategy",
45
+ # 异常
46
+ "NanoAgentError",
47
+ "FatalError",
48
+ ]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from nanoagent.core.message import Message
7
+ from nanoagent.core.stop import StopReason
8
+
9
+
10
+ @dataclass
11
+ class Context:
12
+ """一次 agent 运行的上下文。
13
+
14
+ messages 是 append-only 事件日志(完整历史,策略不得删改);
15
+ view() 才是真正发给 LLM 的消息——v0.1 等于全量,v0.3 由上下文策略投影裁剪。
16
+ """
17
+
18
+ messages: list[Message] = field(default_factory=list)
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+ usage: dict[str, int] = field(default_factory=dict) # 跨轮累计 token(熔断/显示用量的唯一入口)
21
+ summary: str | None = None # v0.1 占位:承载 v0.3 压缩摘要产物
22
+ _rendered: list[Message] | None = field(default=None, repr=False) # before_model 写入的裁剪视图
23
+
24
+ def add(self, message: Message) -> None:
25
+ self.messages.append(message)
26
+ self._rendered = None # 历史变了,作废旧投影,下次 view() 回落到全量
27
+
28
+ def add_usage(self, usage: dict[str, int]) -> None:
29
+ for k, v in usage.items():
30
+ self.usage[k] = self.usage.get(k, 0) + v
31
+
32
+ def view(self) -> list[Message]:
33
+ """真正发给 LLM 的消息。v0.1:identity=全量历史。
34
+ v0.3:before_model 里的上下文策略调用 set_view() 写入裁剪结果。"""
35
+ return self._rendered if self._rendered is not None else self.messages
36
+
37
+ def set_view(self, messages: list[Message]) -> None:
38
+ """由 before_model 的上下文策略调用,提交本轮发给模型的投影(不改 messages)。"""
39
+ self._rendered = messages
40
+
41
+
42
+ @dataclass
43
+ class AgentResult:
44
+ """一次 agent 运行的最终结果。"""
45
+
46
+ context: Context
47
+ stop_reason: StopReason
48
+ turns: int = 0 # 实际执行轮数(供 CLI 显示「N 轮」)
49
+ usage: dict[str, int] = field(default_factory=dict) # 跨轮累计 token(供 CLI 显示用量)
50
+
51
+ @property
52
+ def output(self) -> str:
53
+ # 回扫最后一条「有内容的 assistant 文本」,避免在 MAX_TURNS(末条是工具结果)
54
+ # 或权限拒绝(末条是 denial)时把非答复内容误当成输出。
55
+ for m in reversed(self.context.messages):
56
+ if m.role == "assistant" and m.content:
57
+ return m.content
58
+ return "" if self.stop_reason is StopReason.DONE else f"[未完成:{self.stop_reason.value}]"
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class NanoAgentError(Exception):
5
+ """nanoagent 所有框架级异常的基类。"""
6
+
7
+
8
+ class FatalError(NanoAgentError):
9
+ """不可恢复的框架错误,直接上抛、不被吞。
10
+
11
+ 与「工具内业务异常」相对:工具异常会被 AgentLoop._invoke 转成
12
+ ToolResult(is_error=True) 喂回模型 self-heal(DESIGN §5.5、§14.3);
13
+ FatalError 表示框架本身处于不应继续的状态,_emit / _invoke 都不得吞它。
14
+ """
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ from nanoagent.core.context import Context
7
+ from nanoagent.core.message import LLMResponse, ToolCall, ToolResult
8
+ from nanoagent.core.stop import StopReason
9
+
10
+
11
+ @dataclass
12
+ class ToolDecision:
13
+ """before_tool 的返回:是否放行一次工具调用。"""
14
+
15
+ allowed: bool = True
16
+ reason: str = ""
17
+
18
+
19
+ class Hook(Protocol):
20
+ """Agent 生命周期钩子。所有 harness 能力都经此注入。
21
+
22
+ 8 个生命周期点中只有 before_tool 有返回值(ToolDecision),因为只有它需要
23
+ 影响核心循环的控制流;其余 7 个是纯观察 / 副作用点。
24
+
25
+ on_start 在整个 run 起始 emit 一次;before_turn 每轮 emit。
26
+ before_compact 为 v0.3 上下文压缩预留:v0.1 的 AgentLoop 不 emit 它(无压缩),
27
+ 与 DESIGN §5.1.1 的占位字段同属「v0.1 声明、暂不驱动」。
28
+ """
29
+
30
+ def on_start(self, ctx: Context) -> None:
31
+ """整个 run 开始时(仅一次)。"""
32
+
33
+ def before_turn(self, ctx: Context) -> None:
34
+ """每一轮 turn 开始前。"""
35
+
36
+ def before_model(self, ctx: Context) -> None:
37
+ """调用 LLM 前。上下文管理策略在此介入(set_view)。"""
38
+
39
+ def after_model(self, ctx: Context, response: LLMResponse) -> None:
40
+ """LLM 返回后。"""
41
+
42
+ def before_compact(self, ctx: Context) -> None:
43
+ """上下文压缩前(v0.3 启用;v0.1 不 emit)。"""
44
+
45
+ def before_tool(self, ctx: Context, call: ToolCall) -> ToolDecision:
46
+ """执行工具前。权限校验策略在此介入,可拒绝调用。"""
47
+
48
+ def after_tool(self, ctx: Context, call: ToolCall, result: ToolResult) -> None:
49
+ """工具执行后。"""
50
+
51
+ def on_stop(self, ctx: Context, reason: StopReason) -> None:
52
+ """循环结束时。"""
53
+
54
+
55
+ class BaseHook:
56
+ """8 个生命周期点的空实现。使用者继承它,只覆盖关心的点。"""
57
+
58
+ def on_start(self, ctx: Context) -> None: ...
59
+ def before_turn(self, ctx: Context) -> None: ...
60
+ def before_model(self, ctx: Context) -> None: ...
61
+ def after_model(self, ctx: Context, response: LLMResponse) -> None: ...
62
+ def before_compact(self, ctx: Context) -> None: ...
63
+ def before_tool(self, ctx: Context, call: ToolCall) -> ToolDecision:
64
+ return ToolDecision(allowed=True) # 默认放行,对齐 v0.1 allow-all
65
+ def after_tool(self, ctx: Context, call: ToolCall, result: ToolResult) -> None: ...
66
+ def on_stop(self, ctx: Context, reason: StopReason) -> None: ...
nanoagent/core/loop.py ADDED
@@ -0,0 +1,111 @@
1
+ """AgentLoop —— 全项目的心脏:把 core 契约编排成约 30 行的 ReAct 循环。
2
+
3
+ 主体只含 ReAct 主干(推理 → 工具 → 回填),不含任何具体策略代码——harness 行为都在
4
+ `_emit` / `_gate` / `stop.should_stop` 背后(这条定性规则是硬的,「~30 行」是它的标尺)。
5
+
6
+ 依赖防线(§8.1):本模块属 core,只 import core 自身的子模块,绝不 import strategies/llm/tools/...。
7
+ 默认停止用 core 内置的 `_MaxTurns`(公开可配的 `MaxTurnsStop` 在 strategies/,由 api 或用户显式传入),
8
+ 以免 core 反向依赖 strategies。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from nanoagent.core.context import AgentResult, Context
15
+ from nanoagent.core.errors import FatalError
16
+ from nanoagent.core.hooks import ToolDecision
17
+ from nanoagent.core.message import Message, ToolCall, ToolResult
18
+ from nanoagent.core.stop import StopReason
19
+
20
+
21
+ class _MaxTurns:
22
+ """core 内置默认停止:达 max_turns 即停。等价于 strategies.MaxTurnsStop,
23
+ 放 core 是为了让 AgentLoop 不依赖 strategies(§8.1)。"""
24
+
25
+ def __init__(self, max_turns: int):
26
+ self.max_turns = max_turns
27
+
28
+ def should_stop(self, ctx: Context, turn: int):
29
+ return StopReason.MAX_TURNS if turn >= self.max_turns else None
30
+
31
+
32
+ def _stringify(value) -> str:
33
+ """工具返回值 → str(无损:str 原样、其余 json.dumps 兜底)。core 绝不截断(那是 v0.3 ContextStrategy 的事)。"""
34
+ if isinstance(value, str):
35
+ return value
36
+ try:
37
+ return json.dumps(value, ensure_ascii=False, default=str)
38
+ except Exception:
39
+ return str(value)
40
+
41
+
42
+ def _denial_message(call: ToolCall, reason: str) -> Message:
43
+ """被拒工具调用 → role=tool 且 call_id 配对的消息,保证 OpenAI tool_call/tool 配对完整(否则下一轮 400)。"""
44
+ return Message(
45
+ role="tool",
46
+ tool_result=ToolResult(call.id, content=f"调用被拒绝:{reason}", is_error=True),
47
+ )
48
+
49
+
50
+ class AgentLoop:
51
+ """约 30 行的 ReAct 核心循环。无状态:状态全在传入的 Context 里。"""
52
+
53
+ def __init__(self, llm, tools, hooks=None, stop=None, max_turns: int = 20):
54
+ self.llm = llm
55
+ self.tools = {t.name: t for t in tools}
56
+ self.hooks = hooks or []
57
+ self.stop = stop or _MaxTurns(max_turns) # 默认 max-turns;保持 noop 纯 ReAct
58
+
59
+ def run(self, ctx: Context) -> AgentResult:
60
+ self._emit("on_start", ctx) # 整个 run 起始一次
61
+ turn = 0
62
+ while True:
63
+ reason = self.stop.should_stop(ctx, turn) # 轮次 / 预算 / 成本统一在此判定
64
+ if reason is not None:
65
+ self._emit("on_stop", ctx, reason)
66
+ return AgentResult(ctx, reason, turn, ctx.usage)
67
+
68
+ self._emit("before_turn", ctx) # 每一轮 turn 开始
69
+ self._emit("before_model", ctx) # 上下文策略在此 set_view()
70
+ response = self.llm.chat(ctx.view(), tools=list(self.tools.values()))
71
+ self._emit("after_model", ctx, response)
72
+ ctx.add(response.message)
73
+ ctx.add_usage(response.usage)
74
+
75
+ if not response.message.tool_calls: # 终态:模型不再调工具
76
+ self._emit("on_stop", ctx, StopReason.DONE)
77
+ return AgentResult(ctx, StopReason.DONE, turn + 1, ctx.usage)
78
+
79
+ for call in response.message.tool_calls:
80
+ decision = self._gate(ctx, call) # before_tool:软拒绝 → continue
81
+ if not decision.allowed:
82
+ ctx.add(_denial_message(call, decision.reason))
83
+ continue
84
+ result = self._invoke(call)
85
+ self._emit("after_tool", ctx, call, result)
86
+ ctx.add(Message(role="tool", tool_result=result))
87
+ turn += 1
88
+
89
+ # —— 辅助函数(不铺进主体;harness 行为都在这背后)——
90
+
91
+ def _emit(self, point: str, *args) -> None:
92
+ for h in self.hooks:
93
+ getattr(h, point)(*args) # 调每个 hook 的同名方法;v0.1 不吞异常,直接上抛
94
+
95
+ def _gate(self, ctx: Context, call: ToolCall) -> ToolDecision:
96
+ for h in self.hooks: # deny-first:第一个拒绝者说了算
97
+ d = h.before_tool(ctx, call)
98
+ if d is not None and not d.allowed:
99
+ return d
100
+ return ToolDecision(allowed=True) # 无 hook / 全放行 → 默认放行
101
+
102
+ def _invoke(self, call: ToolCall) -> ToolResult:
103
+ if call.name not in self.tools: # 模型可能幻觉工具名 → 喂回错误而非抛
104
+ return ToolResult(call.id, content=f"unknown tool: {call.name}", is_error=True)
105
+ try:
106
+ value = self.tools[call.name](**call.arguments)
107
+ return ToolResult(call.id, content=_stringify(value))
108
+ except FatalError:
109
+ raise # 框架级致命错误:上抛,不喂回
110
+ except Exception as e: # 工具业务异常:转 is_error 喂回模型 self-heal
111
+ return ToolResult(call.id, content=f"{type(e).__name__}: {e}", is_error=True)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class ToolCall:
9
+ """模型发起的一次工具调用请求。"""
10
+
11
+ id: str
12
+ name: str
13
+ arguments: dict[str, Any]
14
+
15
+
16
+ @dataclass
17
+ class ToolResult:
18
+ """一次工具执行的结果。"""
19
+
20
+ call_id: str
21
+ content: str # 工具返回值经字符串化后的内容(core 只做无损 str(),不截断)
22
+ is_error: bool = False
23
+ raw_bytes: int = 0 # v0.1 占位:content 字节数,供 v0.3 策略判断是否需清理
24
+ elided: bool = False # v0.1 占位:v0.3 上下文策略可据此把旧工具结果剔出视图(数据仍留 messages)
25
+
26
+
27
+ @dataclass
28
+ class Message:
29
+ """对话中的一条消息。一旦 add 进 Context 即视为不可变事件,策略不得删改它。"""
30
+
31
+ role: str # "system" | "user" | "assistant" | "tool"
32
+ content: str = ""
33
+ tool_calls: list[ToolCall] = field(default_factory=list)
34
+ tool_result: ToolResult | None = None
35
+ # ↓ 以下为 v0.3 上下文工程预留的「抓手」,v0.1 仅填充、绝不消费(见 DESIGN §5.1.1)
36
+ pinned: bool = False # True=裁剪策略不得丢弃(如 system / 关键决策)
37
+ ephemeral: bool = False # True=可被工具结果清理策略优先移出视图
38
+ token_estimate: int | None = None # 由上下文策略按需回填,供按预算截断/熔断判定
39
+
40
+
41
+ @dataclass
42
+ class LLMResponse:
43
+ """一次 LLM 调用的返回。assistant 消息自带的 tool_calls 即本轮工具调用(唯一权威源)。"""
44
+
45
+ message: Message
46
+ usage: dict[str, int] = field(default_factory=dict) # 本轮 token 统计
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+ from nanoagent.core.context import Context
6
+ from nanoagent.core.hooks import ToolDecision
7
+ from nanoagent.core.message import LLMResponse, Message, ToolCall
8
+ from nanoagent.core.stop import StopReason
9
+
10
+
11
+ class LLMClient(Protocol):
12
+ """LLM 客户端契约,统一 OpenAI / Anthropic / 本地模型。
13
+
14
+ v0.1 只要求 chat(同步、整段返回)。流式(chat_stream)是 v0.2 的预留扩展:
15
+ 届时新增一个 StreamingLLMClient 协议、由 REPL 旁路直接调用,不改 core
16
+ (见 DESIGN §14.3 REPL 流式输出)。
17
+ """
18
+
19
+ def chat(
20
+ self,
21
+ messages: list[Message],
22
+ tools: list[Tool] | None = None,
23
+ **kwargs: Any,
24
+ ) -> LLMResponse:
25
+ ...
26
+
27
+
28
+ class Tool(Protocol):
29
+ """工具契约。用户日常用 @tool 装饰器,框架内部按此协议处理。"""
30
+
31
+ name: str
32
+ description: str
33
+ schema: dict # OpenAI Function Calling 的 JSON Schema
34
+
35
+ def __call__(self, **kwargs: Any) -> Any:
36
+ ...
37
+
38
+
39
+ class MemoryBackend(Protocol):
40
+ """Memory 契约,working / episodic / semantic 三层均符合此接口。"""
41
+
42
+ def store(self, key: str, value: Any, metadata: dict | None = None) -> None:
43
+ ...
44
+
45
+ def retrieve(self, query: str, k: int = 5) -> list[Any]:
46
+ ...
47
+
48
+ def delete(self, key: str) -> None:
49
+ ...
50
+
51
+
52
+ # —— 以下三个是「策略 Protocol」:契约属 core,实现属 strategies/(见 DESIGN §5.4 归属规则)。
53
+ # core 依赖 core 内的协议不违反单向依赖。
54
+
55
+ class ContextStrategy(Protocol):
56
+ """上下文管理策略,由 before_model hook 调用。
57
+
58
+ 只读 messages(完整日志)、产出裁剪后的 message 列表,再由 hook 写进
59
+ ctx.set_view()——物理上无法破坏历史。产出列表须保持 tool_calls↔tool_result
60
+ 配对完整(删工具结果须连带删其 tool_call)。
61
+ """
62
+
63
+ def reduce(self, messages: list[Message], budget_tokens: int) -> list[Message]:
64
+ ...
65
+
66
+
67
+ class PermissionStrategy(Protocol):
68
+ """权限校验策略,由 before_tool hook 调用。"""
69
+
70
+ def check(self, ctx: Context, call: ToolCall) -> ToolDecision:
71
+ ...
72
+
73
+
74
+ class StopStrategy(Protocol):
75
+ """停止条件策略,由 AgentLoop 每轮检查。"""
76
+
77
+ def should_stop(self, ctx: Context, turn: int) -> StopReason | None:
78
+ ...
nanoagent/core/stop.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class StopReason(Enum):
7
+ """agent loop 的退出原因(DESIGN §2.2、§5.1)。
8
+
9
+ 只有 DONE 表示「任务自然完成」;其余三个都是「被迫终止」。
10
+ DONE 在模型不再调工具时产生;MAX_TURNS / DENIED / BUDGET 统一经每轮
11
+ 轮首的 StopStrategy.should_stop 产生。
12
+ """
13
+
14
+ DONE = "done" # 模型不再调工具,任务完成
15
+ MAX_TURNS = "max_turns" # 达到最大轮数
16
+ DENIED = "denied" # 被权限策略终止
17
+ BUDGET = "budget" # 被熔断器终止
@@ -0,0 +1,13 @@
1
+ """llm/ —— LLMClient 协议的两个实现。
2
+
3
+ - EchoClient:不联网的测试基石,按预设脚本返回 LLMResponse。
4
+ - OpenAICompatClient:包官方 openai SDK,做 core Message ↔ OpenAI dict 双向翻译。
5
+
6
+ 依赖方向:llm 依赖 core(实现 LLMClient 协议),core 不反向依赖 llm。
7
+ openai 是重依赖,仅在 OpenAICompatClient.__init__ 内懒加载——
8
+ 故仅 import 本包不会触发 openai,EchoClient 与双向转换函数都可脱离 openai 单测。
9
+ """
10
+ from nanoagent.llm.echo import EchoClient
11
+ from nanoagent.llm.openai_compat import OpenAICompatClient
12
+
13
+ __all__ = ["EchoClient", "OpenAICompatClient"]
nanoagent/llm/echo.py ADDED
@@ -0,0 +1,24 @@
1
+ """EchoClient —— 测试基石:不联网,按预设脚本返回 LLMResponse。
2
+
3
+ 预设脚本可造出「第一轮返回带 tool_calls 的 assistant、第二轮返回纯文本」的序列,
4
+ 从而在不联网下测出 loop 的「调工具 → 回填 → 再推理 → DONE」完整路径(DESIGN §5.2 / v0.1-design §5.2)。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from nanoagent.core import LLMResponse, Message
9
+
10
+
11
+ class EchoClient:
12
+ """实现 LLMClient 协议(结构化,无需继承)。不联网。"""
13
+
14
+ def __init__(self, script: list[LLMResponse] | None = None):
15
+ self._script = list(script or []) # 预设的若干轮响应,按序弹出
16
+
17
+ def chat(self, messages, tools=None, **kwargs) -> LLMResponse:
18
+ if self._script:
19
+ return self._script.pop(0) # 有脚本:按序返回预设响应
20
+ last = messages[-1].content if messages else ""
21
+ return LLMResponse(
22
+ message=Message(role="assistant", content=f"echo: {last}"),
23
+ usage={"total_tokens": 1},
24
+ )