spineagent 0.0.1__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.
- spineagent/__init__.py +121 -0
- spineagent/agent/__init__.py +1 -0
- spineagent/agent/agent.py +122 -0
- spineagent/agent/as_tool.py +30 -0
- spineagent/agent/function_calling.py +129 -0
- spineagent/agent/policy.py +121 -0
- spineagent/agent/tool_using.py +107 -0
- spineagent/conformance.py +128 -0
- spineagent/llm/__init__.py +5 -0
- spineagent/llm/bedrock_provider.py +164 -0
- spineagent/llm/cohere_provider.py +96 -0
- spineagent/llm/gemini_provider.py +175 -0
- spineagent/llm/provider.py +283 -0
- spineagent/orchestration/__init__.py +1 -0
- spineagent/orchestration/chain.py +49 -0
- spineagent/orchestration/coordinator.py +96 -0
- spineagent/protocol/__init__.py +1 -0
- spineagent/protocol/a2a/__init__.py +19 -0
- spineagent/protocol/a2a/seam.py +126 -0
- spineagent/protocol/mcp/__init__.py +21 -0
- spineagent/protocol/mcp/seam.py +126 -0
- spineagent/py.typed +0 -0
- spineagent/tools/__init__.py +1 -0
- spineagent/tools/function_tool.py +88 -0
- spineagent/tools/tool.py +93 -0
- spineagent-0.0.1.dist-info/METADATA +175 -0
- spineagent-0.0.1.dist-info/RECORD +28 -0
- spineagent-0.0.1.dist-info/WHEEL +4 -0
spineagent/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""spineagent —— 通用多 agent 协作框架(ADR 0001 D1),依赖薄核 corespine。
|
|
2
|
+
|
|
3
|
+
agent / tool / 编排 + MCP / A2A 协议缝。复用 corespine 的缝元模式(Protocol + 离线确定性
|
|
4
|
+
默认 + Registry 工厂 + 参数化 conformance)、隐私安全 observability 与 env 配置风格;核心
|
|
5
|
+
默认路径【零网络、零重依赖、离线可跑】,真实协议 SDK 仅经可选 extra 延迟 import。
|
|
6
|
+
|
|
7
|
+
运行时可把 ragspine 当作一个 Tool / MCP server 在【运行时】组合调用(ADR 0001 D4b),但本包
|
|
8
|
+
【不】在包层面依赖 ragspine。详见 CLAUDE.md 宪章与家族 ADR 0001。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from spineagent.agent.agent import Agent, AgentResult, FunctionAgent, LlmAgent
|
|
12
|
+
from spineagent.agent.as_tool import AgentTool
|
|
13
|
+
from spineagent.agent.function_calling import FunctionCallingAgent
|
|
14
|
+
from spineagent.agent.policy import (
|
|
15
|
+
Action,
|
|
16
|
+
Finish,
|
|
17
|
+
Observation,
|
|
18
|
+
SyntaxToolPolicy,
|
|
19
|
+
ToolCall,
|
|
20
|
+
ToolPolicy,
|
|
21
|
+
tool_policies,
|
|
22
|
+
)
|
|
23
|
+
from spineagent.agent.tool_using import ToolUsingAgent
|
|
24
|
+
from spineagent.conformance import AGENT_INVARIANTS, POLICY_INVARIANTS, TOOL_INVARIANTS
|
|
25
|
+
from spineagent.llm.bedrock_provider import BedrockConverseProvider, load_boto3_sdk
|
|
26
|
+
from spineagent.llm.cohere_provider import CohereProvider, load_cohere_sdk
|
|
27
|
+
from spineagent.llm.gemini_provider import GeminiProvider, load_gemini_sdk
|
|
28
|
+
from spineagent.llm.provider import (
|
|
29
|
+
AnthropicProvider,
|
|
30
|
+
OpenAICompatProvider,
|
|
31
|
+
llm_providers,
|
|
32
|
+
load_anthropic_sdk,
|
|
33
|
+
load_openai_sdk,
|
|
34
|
+
)
|
|
35
|
+
from spineagent.orchestration.chain import ChainAgent
|
|
36
|
+
from spineagent.orchestration.coordinator import Coordinator
|
|
37
|
+
from spineagent.protocol.a2a.seam import (
|
|
38
|
+
A2AAgent,
|
|
39
|
+
A2AAgentAdapter,
|
|
40
|
+
A2AResult,
|
|
41
|
+
A2ATask,
|
|
42
|
+
OfflineA2AStub,
|
|
43
|
+
a2a_agents,
|
|
44
|
+
load_a2a_sdk,
|
|
45
|
+
)
|
|
46
|
+
from spineagent.protocol.mcp.seam import (
|
|
47
|
+
McpClient,
|
|
48
|
+
McpClientTool,
|
|
49
|
+
McpServer,
|
|
50
|
+
McpTool,
|
|
51
|
+
OfflineMcpStub,
|
|
52
|
+
load_mcp_sdk,
|
|
53
|
+
mcp_clients,
|
|
54
|
+
)
|
|
55
|
+
from spineagent.tools.function_tool import FunctionTool, function_tool
|
|
56
|
+
from spineagent.tools.tool import CalcTool, EchoTool, Tool, ToolResult, tool_registry
|
|
57
|
+
|
|
58
|
+
__version__ = "0.0.1"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
# agent
|
|
62
|
+
"Agent",
|
|
63
|
+
"AgentResult",
|
|
64
|
+
"LlmAgent",
|
|
65
|
+
"FunctionAgent",
|
|
66
|
+
"ToolUsingAgent",
|
|
67
|
+
"AgentTool",
|
|
68
|
+
"FunctionCallingAgent",
|
|
69
|
+
# tool-policy 缝(会用工具的 agent 的「大脑」)
|
|
70
|
+
"ToolPolicy",
|
|
71
|
+
"ToolCall",
|
|
72
|
+
"Finish",
|
|
73
|
+
"Action",
|
|
74
|
+
"Observation",
|
|
75
|
+
"SyntaxToolPolicy",
|
|
76
|
+
"tool_policies",
|
|
77
|
+
# tools
|
|
78
|
+
"Tool",
|
|
79
|
+
"ToolResult",
|
|
80
|
+
"EchoTool",
|
|
81
|
+
"CalcTool",
|
|
82
|
+
"tool_registry",
|
|
83
|
+
"FunctionTool",
|
|
84
|
+
"function_tool",
|
|
85
|
+
# orchestration
|
|
86
|
+
"Coordinator",
|
|
87
|
+
"ChainAgent",
|
|
88
|
+
# llm provider 适配器(挂在 corespine LLMProvider 缝后面;输出统一 OpenAI ChatCompletion)
|
|
89
|
+
"AnthropicProvider",
|
|
90
|
+
"OpenAICompatProvider",
|
|
91
|
+
"CohereProvider",
|
|
92
|
+
"GeminiProvider",
|
|
93
|
+
"BedrockConverseProvider",
|
|
94
|
+
"llm_providers",
|
|
95
|
+
"load_anthropic_sdk",
|
|
96
|
+
"load_openai_sdk",
|
|
97
|
+
"load_cohere_sdk",
|
|
98
|
+
"load_gemini_sdk",
|
|
99
|
+
"load_boto3_sdk",
|
|
100
|
+
# protocol: mcp
|
|
101
|
+
"McpClient",
|
|
102
|
+
"McpServer",
|
|
103
|
+
"McpTool",
|
|
104
|
+
"McpClientTool",
|
|
105
|
+
"OfflineMcpStub",
|
|
106
|
+
"mcp_clients",
|
|
107
|
+
"load_mcp_sdk",
|
|
108
|
+
# protocol: a2a
|
|
109
|
+
"A2AAgent",
|
|
110
|
+
"A2ATask",
|
|
111
|
+
"A2AResult",
|
|
112
|
+
"A2AAgentAdapter",
|
|
113
|
+
"OfflineA2AStub",
|
|
114
|
+
"a2a_agents",
|
|
115
|
+
"load_a2a_sdk",
|
|
116
|
+
# conformance (本包绑定的不变量)
|
|
117
|
+
"AGENT_INVARIANTS",
|
|
118
|
+
"TOOL_INVARIANTS",
|
|
119
|
+
"POLICY_INVARIANTS",
|
|
120
|
+
"__version__",
|
|
121
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""spineagent.agent —— Agent 协议 + 最小默认实现(LlmAgent / FunctionAgent)。"""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""agent 缝:Agent 协议 + 最小默认实现(单步执行)。
|
|
2
|
+
|
|
3
|
+
家族缝的元模式:Protocol + 离线确定性默认 + 隐私安全 trace。Agent 是 spineagent 最小
|
|
4
|
+
的执行单元——给一个任务,跑【一步】拿回结果。两个默认实现都离线可跑、零网络:
|
|
5
|
+
|
|
6
|
+
- LlmAgent —— 用一个 corespine LLMProvider 跑单步(离线用 MockProvider,确定性、可复现);
|
|
7
|
+
- FunctionAgent —— 把一个纯函数 (task->text) 包成 Agent(无需 LLM,做测试/编排的轻量节点)。
|
|
8
|
+
|
|
9
|
+
隐私约定:step 可选接收一个 corespine TraceSink,实现只允许往里记【元数据】(agent 名、
|
|
10
|
+
长度、token 数),【绝不】记任务/输出正文——由 InProcessPrivacyTraceSink「构造即保证」,
|
|
11
|
+
本包再用 conformance 把这条不变量绑死(见 spineagent/conformance.py)。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from corespine.llm.provider import LLMProvider
|
|
21
|
+
from corespine.observability.trace import TraceSink
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AgentResult:
|
|
26
|
+
"""一次 agent 步的结果:产出文本 + 来源 agent 名(provenance)+ 可选 token 用量 / 错误。
|
|
27
|
+
|
|
28
|
+
error 仅在【编排层弹性模式】下捕获 agent.step 异常时填充——归一为家族统一的可序列化错误
|
|
29
|
+
dict(corespine.errors.error_to_dict:含 code / retryable / context)。正常成功路径 error 为
|
|
30
|
+
None;agent.step 自身的契约仍是「成功产出非空、失败抛异常」,捕获与否是 Coordinator 的策略。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
agent: str
|
|
34
|
+
output: str
|
|
35
|
+
usage: dict[str, int] | None = None
|
|
36
|
+
error: dict[str, object] | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def ok(self) -> bool:
|
|
40
|
+
"""这步是否成功(未捕获到错误)。"""
|
|
41
|
+
return self.error is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class Agent(Protocol):
|
|
46
|
+
"""agent 协议:有名字;给一个任务,跑【一步】拿回结果。
|
|
47
|
+
|
|
48
|
+
step 可选接收一个 TraceSink:实现只允许往里记元数据(code/计数/耗时),绝不记任务/
|
|
49
|
+
输出正文——隐私 by construction,由 corespine 的 InProcessPrivacyTraceSink 兜底。
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
|
|
54
|
+
def step(self, task: str, *, trace: TraceSink | None = None) -> AgentResult: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LlmAgent:
|
|
58
|
+
"""最小默认 agent:用一个 corespine LLMProvider 跑单步(离线用 MockProvider)。"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, name: str, provider: LLMProvider, *, system: str = "") -> None:
|
|
61
|
+
self._name = name
|
|
62
|
+
self._provider = provider
|
|
63
|
+
self._system = system
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def name(self) -> str:
|
|
67
|
+
return self._name
|
|
68
|
+
|
|
69
|
+
def step(self, task: str, *, trace: TraceSink | None = None) -> AgentResult:
|
|
70
|
+
messages: list[dict[str, str]] = [{"role": "user", "content": task}]
|
|
71
|
+
if self._system:
|
|
72
|
+
messages.insert(0, {"role": "system", "content": self._system})
|
|
73
|
+
completion = self._provider.chat(messages)
|
|
74
|
+
message = completion.choices[0].message
|
|
75
|
+
usage = (
|
|
76
|
+
{
|
|
77
|
+
"prompt_tokens": completion.usage.prompt_tokens,
|
|
78
|
+
"completion_tokens": completion.usage.completion_tokens,
|
|
79
|
+
"total_tokens": completion.usage.total_tokens,
|
|
80
|
+
}
|
|
81
|
+
if completion.usage is not None
|
|
82
|
+
else None
|
|
83
|
+
)
|
|
84
|
+
result = AgentResult(agent=self._name, output=message.content or "", usage=usage)
|
|
85
|
+
_emit_step(trace, self._name, task, result)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FunctionAgent:
|
|
90
|
+
"""最小确定性 agent:把一个纯函数 (task->text) 包成 Agent(离线/测试/编排用,无需 LLM)。"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, name: str, fn: Callable[[str], str]) -> None:
|
|
93
|
+
self._name = name
|
|
94
|
+
self._fn = fn
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def name(self) -> str:
|
|
98
|
+
return self._name
|
|
99
|
+
|
|
100
|
+
def step(self, task: str, *, trace: TraceSink | None = None) -> AgentResult:
|
|
101
|
+
result = AgentResult(agent=self._name, output=self._fn(task))
|
|
102
|
+
_emit_step(trace, self._name, task, result)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _emit_step(
|
|
107
|
+
trace: TraceSink | None, name: str, task: str, result: AgentResult
|
|
108
|
+
) -> None:
|
|
109
|
+
"""记一条隐私安全的步级 trace:只记 agent 名 + 长度 + token 数,绝不记正文。"""
|
|
110
|
+
if trace is None:
|
|
111
|
+
return
|
|
112
|
+
usage = result.usage or {}
|
|
113
|
+
# trace 字段名沿用 input/output_tokens(隐私元数据词表);取值兼容 OpenAI usage 的 prompt/
|
|
114
|
+
# completion_tokens 与旧式 input/output_tokens 两种键。
|
|
115
|
+
trace.emit(
|
|
116
|
+
"agent_step",
|
|
117
|
+
agent=name,
|
|
118
|
+
task_chars=len(task),
|
|
119
|
+
output_chars=len(result.output),
|
|
120
|
+
input_tokens=usage.get("prompt_tokens", usage.get("input_tokens", 0)),
|
|
121
|
+
output_tokens=usage.get("completion_tokens", usage.get("output_tokens", 0)),
|
|
122
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""把一个 Agent 暴露成 Tool:让一个 agent 把另一个 agent 当工具调用(分层 / 督导式多 agent)。
|
|
2
|
+
|
|
3
|
+
AgentTool 是把现有原语组合出【分层多 agent】的关键一块:一个督导 agent(ToolUsingAgent)通过
|
|
4
|
+
工具调用把子任务派给专精的子 agent——子 agent 也可以自己再用工具、再带子 agent,层层嵌套。
|
|
5
|
+
它与 McpClientTool(外部 MCP 工具 → Tool)、A2AAgentAdapter(远端 A2A agent → Agent)构成完整
|
|
6
|
+
的桥接三角:本地 agent → Tool。
|
|
7
|
+
|
|
8
|
+
run(arg) 即对子 agent 跑一步、取其输出包成带 provenance 的 ToolResult(tool = 工具名,默认取
|
|
9
|
+
子 agent 名,可溯源到产出它的子 agent)。最薄桥接:只搬运文本(子 agent 的 usage / error 不
|
|
10
|
+
透传);子 agent 抛异常照常上抛,与其它 Tool 一致——错误处理归编排层 / 调用方(见 Coordinator
|
|
11
|
+
弹性容错)。Tool 协议的 run 无 trace 形参,故 AgentTool 自身不发 trace,外层 agent 循环会为这次
|
|
12
|
+
工具调用发一条隐私安全的 tool_step(见 agent/tool_using.py)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from spineagent.agent.agent import Agent
|
|
18
|
+
from spineagent.tools.tool import ToolResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AgentTool:
|
|
22
|
+
"""跨原语适配器:把一个 Agent 桥成 Tool(实现 Tool 协议),用于分层 / 督导式多 agent。"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, agent: Agent, *, name: str | None = None) -> None:
|
|
25
|
+
self._agent = agent
|
|
26
|
+
self.name = name or agent.name
|
|
27
|
+
|
|
28
|
+
def run(self, arg: str) -> ToolResult:
|
|
29
|
+
result = self._agent.step(arg)
|
|
30
|
+
return ToolResult(tool=self.name, output=result.output)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""真 function-calling 的 agent:用一个 chat 模型的【原生工具调用】跑多步循环。
|
|
2
|
+
|
|
3
|
+
与离线确定性的 ToolUsingAgent(SyntaxToolPolicy 按语法路由)不同,FunctionCallingAgent 让【真 LLM】
|
|
4
|
+
自己决定调哪个工具:把 FunctionTool 的 schema 喂给 model.chat(tools=...),模型回 tool_calls →
|
|
5
|
+
按名执行工具 → 把结果以 OpenAI tool 角色消息喂回 → 再 chat,直到模型不再要工具(出文本)或触顶
|
|
6
|
+
max_steps。它实现 Agent 协议,故可直接进 Coordinator / 被 AgentTool 当工具 / 套进 ChainAgent。
|
|
7
|
+
|
|
8
|
+
对外只认 corespine 的 OpenAI-canonical chat 缝(ChatCompletion + OpenAI message dicts),所以底层换
|
|
9
|
+
任意 provider(OpenAI 兼容 / Anthropic / Gemini / Bedrock / Cohere)都不改这里一行——「统一 invoke」。
|
|
10
|
+
离线默认 MockProvider 不回 tool_calls,故它直接出文本(诚实:离线不假装会 function-calling)。
|
|
11
|
+
|
|
12
|
+
隐私:每步发 tool_step(agent / 步序 / 工具名 / 入参长度 / 输出长度)、收尾发 agent_finish、触顶发
|
|
13
|
+
agent_step_limit——只记 code / 计数,绝不记任务 / 参数 / 输出正文。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from corespine.llm.provider import LLMProvider
|
|
23
|
+
from corespine.observability.trace import TraceSink
|
|
24
|
+
|
|
25
|
+
from spineagent.agent.agent import AgentResult
|
|
26
|
+
from spineagent.tools.function_tool import FunctionTool
|
|
27
|
+
|
|
28
|
+
# 触顶 max_steps 仍未出最终文本时的兜底文案(保证产出非空)。
|
|
29
|
+
_NO_OUTPUT = "(reached max_steps without a final answer)"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FunctionCallingAgent:
|
|
33
|
+
"""用真 LLM 的原生 function-calling 在单次 step() 内多步调用工具的 agent(实现 Agent 协议)。"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
name: str,
|
|
38
|
+
model: LLMProvider,
|
|
39
|
+
tools: Iterable[FunctionTool],
|
|
40
|
+
*,
|
|
41
|
+
system: str = "",
|
|
42
|
+
max_steps: int = 8,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._name = name
|
|
45
|
+
self._model = model
|
|
46
|
+
self._tools = {tool.name: tool for tool in tools}
|
|
47
|
+
self._system = system
|
|
48
|
+
self._max_steps = max_steps
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def name(self) -> str:
|
|
52
|
+
return self._name
|
|
53
|
+
|
|
54
|
+
def step(self, task: str, *, trace: TraceSink | None = None) -> AgentResult:
|
|
55
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": task}]
|
|
56
|
+
if self._system:
|
|
57
|
+
messages.insert(0, {"role": "system", "content": self._system})
|
|
58
|
+
schemas = [tool.schema() for tool in self._tools.values()] or None
|
|
59
|
+
last_usage: dict[str, int] | None = None
|
|
60
|
+
for index in range(self._max_steps):
|
|
61
|
+
result = self._model.chat(messages, tools=schemas)
|
|
62
|
+
last_usage = _usage_dict(result.usage)
|
|
63
|
+
message = result.choices[0].message
|
|
64
|
+
tool_calls = message.tool_calls or ()
|
|
65
|
+
if not tool_calls:
|
|
66
|
+
_emit_finish(trace, self._name, index, message.content or "")
|
|
67
|
+
return AgentResult(self._name, message.content or "", usage=last_usage)
|
|
68
|
+
# 把这一轮的 assistant(带 tool_calls)按 OpenAI 形状追加进对话历史。
|
|
69
|
+
messages.append(
|
|
70
|
+
{
|
|
71
|
+
"role": "assistant",
|
|
72
|
+
"content": message.content,
|
|
73
|
+
"tool_calls": [
|
|
74
|
+
{
|
|
75
|
+
"id": tc.id,
|
|
76
|
+
"type": "function",
|
|
77
|
+
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
|
78
|
+
}
|
|
79
|
+
for tc in tool_calls
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
# 逐个执行工具,把结果以 tool 角色消息喂回(tool_call_id 对齐)。
|
|
84
|
+
for tc in tool_calls:
|
|
85
|
+
tool = self._tools.get(tc.function.name)
|
|
86
|
+
arguments = tc.function.arguments or "{}"
|
|
87
|
+
if tool is None:
|
|
88
|
+
output = f"error: unknown tool {tc.function.name!r}"
|
|
89
|
+
else:
|
|
90
|
+
output = tool.invoke(json.loads(arguments))
|
|
91
|
+
messages.append({"role": "tool", "tool_call_id": tc.id, "content": output})
|
|
92
|
+
_emit_tool_step(trace, self._name, index, tc.function.name, arguments, output)
|
|
93
|
+
# 触顶 max_steps 仍在要工具:强制收尾(兜底非空)。
|
|
94
|
+
_emit_step_limit(trace, self._name, self._max_steps)
|
|
95
|
+
_emit_finish(trace, self._name, self._max_steps, _NO_OUTPUT)
|
|
96
|
+
return AgentResult(self._name, _NO_OUTPUT, usage=last_usage)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _usage_dict(usage: Any) -> dict[str, int] | None:
|
|
100
|
+
if usage is None:
|
|
101
|
+
return None
|
|
102
|
+
return {
|
|
103
|
+
"prompt_tokens": usage.prompt_tokens,
|
|
104
|
+
"completion_tokens": usage.completion_tokens,
|
|
105
|
+
"total_tokens": usage.total_tokens,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _emit_tool_step(
|
|
110
|
+
trace: TraceSink | None, name: str, step: int, tool: str, arguments: str, output: str
|
|
111
|
+
) -> None:
|
|
112
|
+
"""隐私安全步级 trace:agent 名 / 步序 / 工具名 / 入参与输出长度,绝不记正文。"""
|
|
113
|
+
if trace is None:
|
|
114
|
+
return
|
|
115
|
+
trace.emit(
|
|
116
|
+
"tool_step", agent=name, step=step, tool=tool, arg_chars=len(arguments), output_chars=len(output)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _emit_finish(trace: TraceSink | None, name: str, steps: int, answer: str) -> None:
|
|
121
|
+
if trace is None:
|
|
122
|
+
return
|
|
123
|
+
trace.emit("agent_finish", agent=name, steps=steps, answer_chars=len(answer))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _emit_step_limit(trace: TraceSink | None, name: str, max_steps: int) -> None:
|
|
127
|
+
if trace is None:
|
|
128
|
+
return
|
|
129
|
+
trace.emit("agent_step_limit", agent=name, max_steps=max_steps)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""tool-policy 缝:ToolPolicy 协议 + 离线确定性默认(决定 agent 下一步调哪个工具)。
|
|
2
|
+
|
|
3
|
+
家族缝的元模式(同 mcp / a2a 缝):Protocol + 离线确定性默认 + Registry 工厂 + 参数化
|
|
4
|
+
conformance。一个 ToolPolicy 是「会用工具的 agent」的大脑:给一个任务、当前可用的工具名
|
|
5
|
+
集合、以及已执行步的观测历史,定下一个动作——【调某个工具】或【收尾给最终答案】。
|
|
6
|
+
|
|
7
|
+
【离线默认为何不靠 LLM 推理(诚实性取舍)】corespine 的 MockProvider 只做「回声 + sha256
|
|
8
|
+
指纹」,对任何 prompt 都吐 `[mock:<hex>] <prompt>`,绝不可能推理出 `{tool: calc, arg: 1+1}`。
|
|
9
|
+
任何「解析 LLM 输出找 tool call」的默认实现,在离线下要么永远解析失败、空转到 max_steps,
|
|
10
|
+
要么把 mock 输出硬塞进解析器自欺。所以离线默认【绝不依赖 LLM 选工具】——正如 MockProvider
|
|
11
|
+
用回声诚实地「不假装生成内容」,SyntaxToolPolicy 把「工具调用意图」显式化为任务文本里的
|
|
12
|
+
确定性语法,纯函数解析。它是这条缝的【确定性参照实现】;真实推理式 policy(走 corespine
|
|
13
|
+
真 provider 解析 function-calling)应注册进 tool_policies 的 'llm' 位,与 mcp/a2a 的
|
|
14
|
+
offline/real 二分完全同构。
|
|
15
|
+
|
|
16
|
+
decide 是【无状态纯函数】:循环状态全由入参 history 携带,同一 (task, tools, history) 恒定
|
|
17
|
+
产出同一 Action——可断言、可复现、零网络,也便于将来直接映射到一次 stateless completion。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Protocol, TypeAlias, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from corespine.errors import SeamError
|
|
26
|
+
from corespine.seam.registry import Registry
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ToolCall:
|
|
31
|
+
"""决定:调一个工具(tool 名 + 传给它的参数)。arg 中字面量 $prev 由 agent 侧替换为上一步观测。"""
|
|
32
|
+
|
|
33
|
+
tool: str
|
|
34
|
+
arg: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Finish:
|
|
39
|
+
"""决定:收尾,给出最终答案(保证非空)。"""
|
|
40
|
+
|
|
41
|
+
answer: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# 一个决策动作:调工具,或收尾。isinstance 分发(PEP 604 联合)。
|
|
45
|
+
Action: TypeAlias = ToolCall | Finish
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Observation:
|
|
50
|
+
"""一步执行的观测:产出它的工具名 + 实际入参 + 输出。喂回循环,供 $prev 链式消费与收尾拼接。"""
|
|
51
|
+
|
|
52
|
+
tool: str
|
|
53
|
+
arg: str
|
|
54
|
+
output: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@runtime_checkable
|
|
58
|
+
class ToolPolicy(Protocol):
|
|
59
|
+
"""tool-policy 协议:给 task + 可用工具名集 + 历史观测,决定下一个动作(调工具 / 收尾)。"""
|
|
60
|
+
|
|
61
|
+
def decide(
|
|
62
|
+
self, task: str, *, tools: tuple[str, ...], history: tuple[Observation, ...]
|
|
63
|
+
) -> Action: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_instruction(line: str, tools: tuple[str, ...]) -> tuple[str, str] | None:
|
|
67
|
+
"""把一行解析成 (工具名, 参数):形如 `<tool>: <arg>` 且工具名在 tools 内才算指令,否则视为正文。"""
|
|
68
|
+
if ":" not in line:
|
|
69
|
+
return None
|
|
70
|
+
name, _, arg = line.partition(":")
|
|
71
|
+
name = name.strip()
|
|
72
|
+
if name in tools:
|
|
73
|
+
return name, arg.strip()
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SyntaxToolPolicy:
|
|
78
|
+
"""离线确定性默认:按任务文本里的 `<tool>: <arg>` 显式语法 + 工具名集合,确定性路由工具调用。
|
|
79
|
+
|
|
80
|
+
无状态纯函数:游标 = len(history) 表示「已执行到第几条工具指令」。第 cursor 条工具指令尚存
|
|
81
|
+
则返回 ToolCall(该行工具名, 该行参数);工具指令耗尽则返回 Finish(把非指令正文行 + 最后一步
|
|
82
|
+
观测按固定模板拼成最终答案,保证非空)。同一输入恒定同一输出。
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def decide(
|
|
86
|
+
self, task: str, *, tools: tuple[str, ...], history: tuple[Observation, ...]
|
|
87
|
+
) -> Action:
|
|
88
|
+
# 单遍把每行分流:能解析成工具指令的入 instructions,其余非空行入 prose 正文。
|
|
89
|
+
instructions: list[tuple[str, str]] = []
|
|
90
|
+
prose: list[str] = []
|
|
91
|
+
for line in task.splitlines():
|
|
92
|
+
parsed = _parse_instruction(line, tools)
|
|
93
|
+
if parsed is not None:
|
|
94
|
+
instructions.append(parsed)
|
|
95
|
+
elif stripped := line.strip():
|
|
96
|
+
prose.append(stripped)
|
|
97
|
+
cursor = len(history)
|
|
98
|
+
if cursor < len(instructions):
|
|
99
|
+
name, arg = instructions[cursor]
|
|
100
|
+
return ToolCall(tool=name, arg=arg)
|
|
101
|
+
# 工具指令耗尽 -> 收尾:非指令正文行 + 最后一步观测输出,拼成非空答案。
|
|
102
|
+
parts = prose + ([history[-1].output] if history else [])
|
|
103
|
+
answer = "\n".join(p for p in parts if p) or task.strip() or "(no output)"
|
|
104
|
+
return Finish(answer=answer)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_llm_policy(**kwargs: Any) -> ToolPolicy:
|
|
108
|
+
# 真实推理式 policy 的占位(与 mcp/a2a 的 'real' 同构)。不同点:LLM policy 走 corespine
|
|
109
|
+
# LLMProvider 真后端,无网络 SDK 可延迟 import,故直接给「缝未接入」的明确报错。
|
|
110
|
+
# 用家族统一 SeamError(code="seam.unknown"):任何「缝槽存在但真实实现未接入」都同一形状。
|
|
111
|
+
raise SeamError(
|
|
112
|
+
"真实 LLM 推理式 ToolPolicy 留待接入:应走 corespine LLMProvider 真后端解析 "
|
|
113
|
+
"function-calling / 工具调用,并注册进 tool_policies 的 'llm' 位;"
|
|
114
|
+
"本壳只提供缝 + 离线确定性默认 SyntaxToolPolicy。"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# 缝注册表:一个 spec 选实现(默认 offline 离线确定性默认;llm 走真实推理式接入)。
|
|
119
|
+
tool_policies: Registry[ToolPolicy] = Registry("tool_policy")
|
|
120
|
+
tool_policies.register("offline", lambda **kw: SyntaxToolPolicy(**kw))
|
|
121
|
+
tool_policies.register("llm", _make_llm_policy)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""会用工具的多步 agent:在一次 step() 内跑「决策→调工具→喂回观测→再决策」的循环。
|
|
2
|
+
|
|
3
|
+
ToolUsingAgent 实现现有 Agent 协议(name + step),因此【零改 Coordinator】即可进顺序 / 并行
|
|
4
|
+
编排。一次 step() 内:用一个 ToolPolicy 决定下一个动作——调某工具则按名取 Tool 执行、把观测
|
|
5
|
+
追加进历史(供下一步 $prev 链式消费)、发一条隐私安全步级 trace;收尾则返回最终 AgentResult。
|
|
6
|
+
|
|
7
|
+
max_steps 守卫:其语义是【最多调用多少次工具】(收尾决策本身不占步预算)。已用满 max_steps
|
|
8
|
+
次工具调用后,policy 若还想再调工具,则强制收尾——即便 policy 异常永不返回 Finish,也绝不
|
|
9
|
+
死循环(history 每步单调增长,必在 max_steps 内触顶)。
|
|
10
|
+
|
|
11
|
+
$prev:工具参数里字面量 `$prev` 在【执行前】替换为上一步观测的输出(history 为空时替换为空
|
|
12
|
+
串),让「把观测喂回循环」名副其实。替换只发生在内存里,trace 只记其长度,绝不写进正文。
|
|
13
|
+
注意:若【首步】即引用 $prev(尚无上一步输出),替换为空串后,余下参数能否被工具处理由工具
|
|
14
|
+
自身决定;工具若拒绝该参数(如 CalcTool 对空串抛错),异常照常上抛——错误处理 / 重试不在本
|
|
15
|
+
增量范围(rule of three),调用方自行处理。
|
|
16
|
+
|
|
17
|
+
隐私 trace:每步发 tool_step(agent / 步序 / 工具名 / 入参长度 / 输出长度),收尾发 agent_finish
|
|
18
|
+
(agent / 总步数 / 答案长度),触顶发 agent_step_limit——字段全为 code / 计数 / 序号,键名全程
|
|
19
|
+
规避 corespine FORBIDDEN_KEYS,绝不携带 task / arg / output / answer 正文。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
|
|
26
|
+
from corespine.observability.trace import TraceSink
|
|
27
|
+
|
|
28
|
+
from spineagent.agent.agent import AgentResult
|
|
29
|
+
from spineagent.agent.policy import Finish, Observation, ToolPolicy
|
|
30
|
+
from spineagent.tools.tool import Tool
|
|
31
|
+
|
|
32
|
+
# 触顶 max_steps 又无任何观测可作答时的固定兜底文案(保证产出非空)。
|
|
33
|
+
_NO_OUTPUT = "(reached max_steps without finishing)"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ToolUsingAgent:
|
|
37
|
+
"""用一个 ToolPolicy 驱动、在单次 step() 内多步调用工具的 agent(实现 Agent 协议)。"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
name: str,
|
|
42
|
+
policy: ToolPolicy,
|
|
43
|
+
tools: Iterable[Tool],
|
|
44
|
+
*,
|
|
45
|
+
max_steps: int = 8,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._name = name
|
|
48
|
+
self._policy = policy
|
|
49
|
+
self._tools = {tool.name: tool for tool in tools}
|
|
50
|
+
# 工具名集合(传给 policy 用以避免幻觉一个不存在的工具);顺序 = 插入序。
|
|
51
|
+
self._tool_names = tuple(self._tools)
|
|
52
|
+
self._max_steps = max_steps
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self) -> str:
|
|
56
|
+
return self._name
|
|
57
|
+
|
|
58
|
+
def step(self, task: str, *, trace: TraceSink | None = None) -> AgentResult:
|
|
59
|
+
history: list[Observation] = []
|
|
60
|
+
while True:
|
|
61
|
+
action = self._policy.decide(
|
|
62
|
+
task, tools=self._tool_names, history=tuple(history)
|
|
63
|
+
)
|
|
64
|
+
if isinstance(action, Finish):
|
|
65
|
+
_emit_finish(trace, self._name, len(history), action.answer)
|
|
66
|
+
return AgentResult(agent=self._name, output=action.answer)
|
|
67
|
+
# ToolCall:已用满 max_steps 次工具调用,policy 还想再调 -> 触顶强制收尾。
|
|
68
|
+
if len(history) >= self._max_steps:
|
|
69
|
+
answer = (history[-1].output if history else "") or _NO_OUTPUT
|
|
70
|
+
_emit_step_limit(trace, self._name, self._max_steps)
|
|
71
|
+
_emit_finish(trace, self._name, len(history), answer)
|
|
72
|
+
return AgentResult(agent=self._name, output=answer)
|
|
73
|
+
# 把 $prev 替换为上一步观测输出后执行该工具,观测追加进历史。
|
|
74
|
+
arg = action.arg.replace("$prev", history[-1].output if history else "")
|
|
75
|
+
result = self._tools[action.tool].run(arg)
|
|
76
|
+
history.append(Observation(tool=action.tool, arg=arg, output=result.output))
|
|
77
|
+
_emit_tool_step(trace, self._name, len(history) - 1, action.tool, arg, result.output)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _emit_tool_step(
|
|
81
|
+
trace: TraceSink | None, name: str, step: int, tool: str, arg: str, output: str
|
|
82
|
+
) -> None:
|
|
83
|
+
"""记一条隐私安全的步级 trace:只记 agent 名 / 步序 / 工具名 / 入参与输出长度,绝不记正文。"""
|
|
84
|
+
if trace is None:
|
|
85
|
+
return
|
|
86
|
+
trace.emit(
|
|
87
|
+
"tool_step",
|
|
88
|
+
agent=name,
|
|
89
|
+
step=step,
|
|
90
|
+
tool=tool,
|
|
91
|
+
arg_chars=len(arg),
|
|
92
|
+
output_chars=len(output),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _emit_finish(trace: TraceSink | None, name: str, steps: int, answer: str) -> None:
|
|
97
|
+
"""记收尾 trace:agent 名 / 总步数 / 答案长度(answer_chars,绝不用 answer 键携带正文)。"""
|
|
98
|
+
if trace is None:
|
|
99
|
+
return
|
|
100
|
+
trace.emit("agent_finish", agent=name, steps=steps, answer_chars=len(answer))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _emit_step_limit(trace: TraceSink | None, name: str, max_steps: int) -> None:
|
|
104
|
+
"""记触顶 trace:便于排障「为何提前收尾」。"""
|
|
105
|
+
if trace is None:
|
|
106
|
+
return
|
|
107
|
+
trace.emit("agent_step_limit", agent=name, max_steps=max_steps)
|