agent-runtime-sdk 0.1.0__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.
- agent_runtime/__init__.py +84 -0
- agent_runtime/builder.py +317 -0
- agent_runtime/config/__init__.py +29 -0
- agent_runtime/config/definitions.py +144 -0
- agent_runtime/config/policies.py +63 -0
- agent_runtime/config/storage.py +117 -0
- agent_runtime/context.py +10 -0
- agent_runtime/definitions.py +33 -0
- agent_runtime/discovery.py +16 -0
- agent_runtime/exceptions.py +74 -0
- agent_runtime/mcp/__init__.py +28 -0
- agent_runtime/mcp/discovery.py +146 -0
- agent_runtime/mcp/metadata.py +68 -0
- agent_runtime/mcp/utils.py +52 -0
- agent_runtime/model_registry.py +40 -0
- agent_runtime/plugins/__init__.py +4 -0
- agent_runtime/plugins/base.py +90 -0
- agent_runtime/plugins/default.py +19 -0
- agent_runtime/plugins/instructions.py +38 -0
- agent_runtime/plugins/loader.py +59 -0
- agent_runtime/policies.py +15 -0
- agent_runtime/runtime.py +110 -0
- agent_runtime/runtime_engine/__init__.py +22 -0
- agent_runtime/runtime_engine/a2a_bridge.py +190 -0
- agent_runtime/runtime_engine/a2a_task_io.py +165 -0
- agent_runtime/runtime_engine/agent_build.py +315 -0
- agent_runtime/runtime_engine/context.py +469 -0
- agent_runtime/runtime_engine/loading.py +170 -0
- agent_runtime/runtime_engine/observability.py +154 -0
- agent_runtime/runtime_engine/policy_registry.py +98 -0
- agent_runtime/runtime_engine/protocol_tools.py +94 -0
- agent_runtime/runtime_engine/task_flow.py +897 -0
- agent_runtime/runtime_engine/tool_flow.py +332 -0
- agent_runtime/sdk_agent.py +548 -0
- agent_runtime/server/__init__.py +15 -0
- agent_runtime/server/app_factory.py +37 -0
- agent_runtime/server/bootstrap.py +48 -0
- agent_runtime/server/endpoint_utils.py +37 -0
- agent_runtime/server/management.py +107 -0
- agent_runtime/smol/__init__.py +4 -0
- agent_runtime/smol/agents.py +431 -0
- agent_runtime/smol/llm_models.py +212 -0
- agent_runtime/smol/memory.py +111 -0
- agent_runtime/smol/models.py +69 -0
- agent_runtime/standalone.py +57 -0
- agent_runtime/storage.py +5 -0
- agent_runtime/tools.py +5 -0
- agent_runtime_sdk-0.1.0.dist-info/METADATA +125 -0
- agent_runtime_sdk-0.1.0.dist-info/RECORD +51 -0
- agent_runtime_sdk-0.1.0.dist-info/WHEEL +5 -0
- agent_runtime_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Langfuse/OpenTelemetry integration for the smolagents runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_FALSE_VALUES = {"0", "false", "no", "off", "disable", "disabled"}
|
|
15
|
+
_SMOLAGENTS_INSTRUMENTED = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class A2ATaskObservation:
|
|
20
|
+
agent_id: str
|
|
21
|
+
task_id: str
|
|
22
|
+
context_id: str | None
|
|
23
|
+
request_headers: dict[str, str]
|
|
24
|
+
task_input: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def setup_smolagents_observability() -> None:
|
|
28
|
+
"""Enable the official smolagents OpenTelemetry instrumentation when configured."""
|
|
29
|
+
|
|
30
|
+
global _SMOLAGENTS_INSTRUMENTED
|
|
31
|
+
if _SMOLAGENTS_INSTRUMENTED or not _langfuse_enabled():
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from langfuse import get_client
|
|
36
|
+
from openinference.instrumentation.smolagents import SmolagentsInstrumentor
|
|
37
|
+
except ImportError:
|
|
38
|
+
logger.info(
|
|
39
|
+
"Langfuse env configured but smolagents telemetry dependencies are not "
|
|
40
|
+
"installed; runtime observability disabled"
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
get_client()
|
|
46
|
+
SmolagentsInstrumentor().instrument()
|
|
47
|
+
except Exception:
|
|
48
|
+
logger.warning("Failed to initialize smolagents observability", exc_info=True)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
_SMOLAGENTS_INSTRUMENTED = True
|
|
52
|
+
logger.info("Smolagents observability instrumentation enabled")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def a2a_task_observation(
|
|
57
|
+
observation: A2ATaskObservation | None,
|
|
58
|
+
) -> Iterator[Any | None]:
|
|
59
|
+
"""Create a thin A2A task span and propagate task metadata to smolagents spans."""
|
|
60
|
+
|
|
61
|
+
if observation is None or not _langfuse_enabled():
|
|
62
|
+
yield None
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from langfuse import get_client, propagate_attributes
|
|
67
|
+
except ImportError:
|
|
68
|
+
yield None
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
langfuse = get_client()
|
|
72
|
+
metadata = _task_metadata(observation)
|
|
73
|
+
user_email = observation.request_headers.get("user_email") or None
|
|
74
|
+
session_id = observation.request_headers.get("X-Session-Id") or None
|
|
75
|
+
|
|
76
|
+
span_cm = langfuse.start_as_current_observation(
|
|
77
|
+
as_type="span",
|
|
78
|
+
name="a2a.task",
|
|
79
|
+
input={"text": observation.task_input},
|
|
80
|
+
metadata=metadata,
|
|
81
|
+
)
|
|
82
|
+
propagate_cm = propagate_attributes(
|
|
83
|
+
user_id=user_email,
|
|
84
|
+
session_id=session_id,
|
|
85
|
+
metadata=metadata,
|
|
86
|
+
trace_name="a2a.task",
|
|
87
|
+
tags=["a2a", observation.agent_id],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with span_cm as span:
|
|
92
|
+
with propagate_cm:
|
|
93
|
+
try:
|
|
94
|
+
yield span
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
_update_observation_error(span, exc)
|
|
97
|
+
raise
|
|
98
|
+
finally:
|
|
99
|
+
_flush_langfuse(langfuse)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def update_observation_output(span: Any | None, output: Any) -> None:
|
|
103
|
+
if span is None:
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
span.update(output=output)
|
|
107
|
+
except Exception:
|
|
108
|
+
logger.debug("Failed to update Langfuse observation output", exc_info=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _langfuse_enabled() -> bool:
|
|
112
|
+
enabled = os.getenv("MCP_AGENT_LANGFUSE_ENABLED")
|
|
113
|
+
if enabled is not None and enabled.strip().lower() in _FALSE_VALUES:
|
|
114
|
+
return False
|
|
115
|
+
return all(
|
|
116
|
+
os.getenv(name)
|
|
117
|
+
for name in (
|
|
118
|
+
"LANGFUSE_PUBLIC_KEY",
|
|
119
|
+
"LANGFUSE_SECRET_KEY",
|
|
120
|
+
"LANGFUSE_BASE_URL",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _task_metadata(observation: A2ATaskObservation) -> dict[str, str]:
|
|
126
|
+
metadata = {
|
|
127
|
+
"agent_id": observation.agent_id,
|
|
128
|
+
"task_id": observation.task_id,
|
|
129
|
+
"context_id": observation.context_id or "",
|
|
130
|
+
"user_email": observation.request_headers.get("user_email", ""),
|
|
131
|
+
"session_id": observation.request_headers.get("X-Session-Id", ""),
|
|
132
|
+
}
|
|
133
|
+
return {key: value for key, value in metadata.items() if value}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _update_observation_error(span: Any, error: Exception) -> None:
|
|
137
|
+
try:
|
|
138
|
+
span.update(
|
|
139
|
+
output={
|
|
140
|
+
"error_type": type(error).__name__,
|
|
141
|
+
"message": str(error),
|
|
142
|
+
},
|
|
143
|
+
level="ERROR",
|
|
144
|
+
status_message=str(error),
|
|
145
|
+
)
|
|
146
|
+
except Exception:
|
|
147
|
+
logger.debug("Failed to update Langfuse observation error", exc_info=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _flush_langfuse(langfuse: Any) -> None:
|
|
151
|
+
try:
|
|
152
|
+
langfuse.flush()
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.debug("Failed to flush Langfuse client", exc_info=True)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""MCP 级工具治理的集中读写逻辑。
|
|
4
|
+
|
|
5
|
+
这一层的目标是把 “tool policy 应该写到哪个 MCP 上” 这件事收敛到一个地方,
|
|
6
|
+
避免 runtime 其他模块到处自己猜。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..config.definitions import MCPSettings, ToolPolicy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MCPPolicyRegistry:
|
|
15
|
+
"""围绕 MCPSettings.tool_policies 提供统一的查询与合并接口。"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, mcps: list[MCPSettings], tool_source_by_name: dict[str, str] | None = None
|
|
19
|
+
):
|
|
20
|
+
self._mcps = mcps
|
|
21
|
+
self._tool_source_by_name = tool_source_by_name or {}
|
|
22
|
+
|
|
23
|
+
def find_mcp(self, mcp_name: str) -> MCPSettings | None:
|
|
24
|
+
return next((mcp for mcp in self._mcps if mcp.name == mcp_name), None)
|
|
25
|
+
|
|
26
|
+
def resolve_policy_mcp(
|
|
27
|
+
self, tool_name: str, mcp_name: str | None = None
|
|
28
|
+
) -> MCPSettings | None:
|
|
29
|
+
if mcp_name is not None:
|
|
30
|
+
return self.find_mcp(mcp_name)
|
|
31
|
+
|
|
32
|
+
configured = [mcp for mcp in self._mcps if tool_name in mcp.tool_policies]
|
|
33
|
+
if len(configured) == 1:
|
|
34
|
+
return configured[0]
|
|
35
|
+
if len(configured) > 1:
|
|
36
|
+
return configured[0]
|
|
37
|
+
|
|
38
|
+
source_mcp = self._tool_source_by_name.get(tool_name)
|
|
39
|
+
if source_mcp:
|
|
40
|
+
return self.find_mcp(source_mcp)
|
|
41
|
+
if len(self._mcps) == 1:
|
|
42
|
+
return self._mcps[0]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def set_tool_policy(
|
|
46
|
+
self, tool_name: str, policy: ToolPolicy, mcp_name: str | None = None
|
|
47
|
+
) -> None:
|
|
48
|
+
target_mcp = self.resolve_policy_mcp(tool_name, mcp_name)
|
|
49
|
+
if target_mcp is None:
|
|
50
|
+
raise ValueError(f"cannot resolve MCP for tool '{tool_name}'")
|
|
51
|
+
target_mcp.tool_policies[tool_name] = policy
|
|
52
|
+
|
|
53
|
+
def get_tool_policy(
|
|
54
|
+
self, tool_name: str, mcp_name: str | None = None
|
|
55
|
+
) -> ToolPolicy | None:
|
|
56
|
+
target_mcp = self.resolve_policy_mcp(tool_name, mcp_name)
|
|
57
|
+
if target_mcp is None:
|
|
58
|
+
return None
|
|
59
|
+
return target_mcp.tool_policies.get(tool_name)
|
|
60
|
+
|
|
61
|
+
def list_tool_policies(self, mcp_name: str | None = None) -> dict[str, ToolPolicy]:
|
|
62
|
+
if mcp_name is not None:
|
|
63
|
+
target_mcp = self.find_mcp(mcp_name)
|
|
64
|
+
return {} if target_mcp is None else dict(target_mcp.tool_policies)
|
|
65
|
+
|
|
66
|
+
policies: dict[str, ToolPolicy] = {}
|
|
67
|
+
for mcp in self._mcps:
|
|
68
|
+
for tool_name, policy in mcp.tool_policies.items():
|
|
69
|
+
policies[tool_name] = policy
|
|
70
|
+
return policies
|
|
71
|
+
|
|
72
|
+
def remove_tool_policy(self, tool_name: str, mcp_name: str | None = None) -> bool:
|
|
73
|
+
target_mcp = self.resolve_policy_mcp(tool_name, mcp_name)
|
|
74
|
+
if target_mcp is None:
|
|
75
|
+
return False
|
|
76
|
+
return target_mcp.tool_policies.pop(tool_name, None) is not None
|
|
77
|
+
|
|
78
|
+
def is_tool_enabled(self, tool_name: str, mcp_name: str | None = None) -> bool:
|
|
79
|
+
policy = self.get_tool_policy(tool_name, mcp_name)
|
|
80
|
+
return True if policy is None else policy.enabled
|
|
81
|
+
|
|
82
|
+
def merged_policy(
|
|
83
|
+
self, tool_name: str, decision: Any, mcp_name: str | None = None
|
|
84
|
+
) -> ToolPolicy:
|
|
85
|
+
policy = (self.get_tool_policy(tool_name, mcp_name) or ToolPolicy()).model_copy(
|
|
86
|
+
deep=True
|
|
87
|
+
)
|
|
88
|
+
if decision is None:
|
|
89
|
+
return policy
|
|
90
|
+
if decision.requires_confirmation is not None:
|
|
91
|
+
policy.requires_confirmation = decision.requires_confirmation
|
|
92
|
+
if decision.input_fields:
|
|
93
|
+
policy.input_fields = decision.input_fields
|
|
94
|
+
if decision.prompt is not None:
|
|
95
|
+
policy.prompt = decision.prompt
|
|
96
|
+
if decision.allow_arg_override is not None:
|
|
97
|
+
policy.allow_arg_override = decision.allow_arg_override
|
|
98
|
+
return policy
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""运行时协议工具。
|
|
2
|
+
|
|
3
|
+
这三个工具不是业务 MCP 工具,而是 runtime 和上层协议之间的桥梁:
|
|
4
|
+
|
|
5
|
+
- `ask_user`: 主动向用户追问缺失信息
|
|
6
|
+
- `ask_auth`: 向用户请求授权确认
|
|
7
|
+
- `final_answer`: 统一输出最终结果
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .context import current_task_id, current_task_pool
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from smolagents import Tool
|
|
18
|
+
except (
|
|
19
|
+
ImportError
|
|
20
|
+
): # pragma: no cover - fallback for local static tests without smolagents
|
|
21
|
+
|
|
22
|
+
class Tool: # type: ignore[no-redef]
|
|
23
|
+
name = ""
|
|
24
|
+
description = ""
|
|
25
|
+
inputs: dict[str, Any] = {}
|
|
26
|
+
output_type = "string"
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _consume_task_input(input_key: str) -> Any:
|
|
33
|
+
try:
|
|
34
|
+
task_id = current_task_id.get()
|
|
35
|
+
pool = current_task_pool.get()
|
|
36
|
+
except LookupError:
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
task_info = pool.get(task_id)
|
|
40
|
+
if not task_info or not hasattr(task_info, input_key):
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
value = getattr(task_info, input_key)
|
|
44
|
+
setattr(task_info, input_key, None)
|
|
45
|
+
return value or ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AskUserTool(Tool):
|
|
49
|
+
name = "ask_user"
|
|
50
|
+
description = "向用户提出问题,补充信息。"
|
|
51
|
+
inputs = {
|
|
52
|
+
"task": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "向用户表达你需要什么信息",
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
output_type = "string"
|
|
58
|
+
|
|
59
|
+
def forward(self, task: str):
|
|
60
|
+
return _consume_task_input("user_input")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AskAuthTool(Tool):
|
|
64
|
+
name = "ask_auth"
|
|
65
|
+
description = (
|
|
66
|
+
"向用户请求授权确认。用户端返回布尔值:true 表示确认,false 表示拒绝。"
|
|
67
|
+
)
|
|
68
|
+
inputs = {
|
|
69
|
+
"task": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "向用户说明需要什么授权信息",
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
output_type = "string"
|
|
75
|
+
|
|
76
|
+
def forward(self, task: str):
|
|
77
|
+
return _consume_task_input("auth_required")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FinalAnswerTool(Tool):
|
|
81
|
+
name = "final_answer"
|
|
82
|
+
description = (
|
|
83
|
+
"agent 最后通过此工具输出最终结果,请确保最后一次调用的工具是 final_answer"
|
|
84
|
+
)
|
|
85
|
+
inputs = {
|
|
86
|
+
"answer": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"description": "最终结果内容",
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
output_type = "string"
|
|
92
|
+
|
|
93
|
+
def forward(self, answer: str):
|
|
94
|
+
return answer
|