allybuild-sdk 1.0.0__tar.gz
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.
- allybuild_sdk-1.0.0/MANIFEST.in +1 -0
- allybuild_sdk-1.0.0/PKG-INFO +7 -0
- allybuild_sdk-1.0.0/allybuild_sdk/__init__.py +33 -0
- allybuild_sdk-1.0.0/allybuild_sdk/agents.py +40 -0
- allybuild_sdk-1.0.0/allybuild_sdk/client.py +79 -0
- allybuild_sdk-1.0.0/allybuild_sdk/mcp.py +298 -0
- allybuild_sdk-1.0.0/allybuild_sdk/memories.py +88 -0
- allybuild_sdk-1.0.0/allybuild_sdk/okf.py +59 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/__init__.py +206 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/actions.py +83 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/backend.py +170 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/descriptors.py +43 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/dollar_api.py +227 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/exceptions.py +4 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/mutations.py +188 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/options_store.py +170 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/plugins.py +54 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/registry.py +141 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/setup_store.py +332 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reactive/types.py +42 -0
- allybuild_sdk-1.0.0/allybuild_sdk/reporter.py +9 -0
- allybuild_sdk-1.0.0/allybuild_sdk/status.py +126 -0
- allybuild_sdk-1.0.0/allybuild_sdk/tasks.py +278 -0
- allybuild_sdk-1.0.0/allybuild_sdk.egg-info/PKG-INFO +7 -0
- allybuild_sdk-1.0.0/allybuild_sdk.egg-info/SOURCES.txt +28 -0
- allybuild_sdk-1.0.0/allybuild_sdk.egg-info/dependency_links.txt +1 -0
- allybuild_sdk-1.0.0/allybuild_sdk.egg-info/top_level.txt +1 -0
- allybuild_sdk-1.0.0/pyproject.toml +14 -0
- allybuild_sdk-1.0.0/setup.cfg +4 -0
- allybuild_sdk-1.0.0/workflow.py +124 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include workflow.py
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""AllyBuild SDK — workflow 平台交互面 + reactive 客户端(纯 HTTP,零第三方依赖)。"""
|
|
2
|
+
from .client import AllyBuildClient, AllyBuildError
|
|
3
|
+
from .reporter import Reporter
|
|
4
|
+
from .tasks import ScriptContext, TasksApi
|
|
5
|
+
from .agents import AgentsApi
|
|
6
|
+
from .memories import MemoriesApi
|
|
7
|
+
from .okf import OkfApi
|
|
8
|
+
from .mcp import McpApi
|
|
9
|
+
from .status import ProjectStatusProxy, fetch_statuses
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AllyBuildClient", "AllyBuildError", "Reporter",
|
|
13
|
+
"ScriptContext", "TasksApi", "AgentsApi", "MemoriesApi", "OkfApi",
|
|
14
|
+
"McpApi", "ProjectStatusProxy", "fetch_statuses",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_client(base_url: str, token: str, project_id: str):
|
|
19
|
+
"""便捷工厂:一次构造 client + 全部域 API(返回 SimpleNamespace)。"""
|
|
20
|
+
from types import SimpleNamespace
|
|
21
|
+
|
|
22
|
+
client = AllyBuildClient(base_url, token, project_id)
|
|
23
|
+
ctx = ScriptContext()
|
|
24
|
+
return SimpleNamespace(
|
|
25
|
+
client=client,
|
|
26
|
+
reporter=Reporter(),
|
|
27
|
+
tasks=TasksApi(client, ctx),
|
|
28
|
+
agents=AgentsApi(client),
|
|
29
|
+
memories=MemoriesApi(client),
|
|
30
|
+
okf=OkfApi(client),
|
|
31
|
+
mcp=McpApi(client),
|
|
32
|
+
status=ProjectStatusProxy(client=client),
|
|
33
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Agent and workspace listing — ported from runtime/environment/python/workflow.py."""
|
|
2
|
+
from .client import AllyBuildClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AgentsApi:
|
|
6
|
+
"""List agents and workspaces in the project."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, client: AllyBuildClient):
|
|
9
|
+
self._client = client
|
|
10
|
+
|
|
11
|
+
def _ensure(self) -> None:
|
|
12
|
+
if not self._client.configured():
|
|
13
|
+
raise RuntimeError("ALLYBUILD_API_URL / PROJECT_ID / TASK_JWT not set")
|
|
14
|
+
|
|
15
|
+
def list_agents(self) -> list:
|
|
16
|
+
"""List all agents in this project.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
List of agent dicts with id, name, description, is_default, agent_type.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
for agent in api.list_agents():
|
|
23
|
+
print(agent["name"], agent["agent_type"])
|
|
24
|
+
"""
|
|
25
|
+
self._ensure()
|
|
26
|
+
return self._client.get(f"/projects/{self._client.project_id}/agents/")
|
|
27
|
+
|
|
28
|
+
def list_workspaces(self) -> list:
|
|
29
|
+
"""List all workspaces in this project.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List of workspace dicts with id, name, slug, type, is_default, artifact_type.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
for ws in api.list_workspaces():
|
|
36
|
+
if ws["artifact_type"] == "knowledge_base":
|
|
37
|
+
print(ws["name"])
|
|
38
|
+
"""
|
|
39
|
+
self._ensure()
|
|
40
|
+
return self._client.get(f"/projects/{self._client.project_id}/workspaces/")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""AllyBuild SDK HTTP client — stdlib only, transport seam injectable."""
|
|
2
|
+
import json
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AllyBuildError(RuntimeError):
|
|
8
|
+
"""HTTP-level error from the AllyBuild API."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, status: int, message: str):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status = status
|
|
13
|
+
self.message = message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AllyBuildClient:
|
|
17
|
+
"""Project-scoped HTTP client for the AllyBuild API.
|
|
18
|
+
|
|
19
|
+
All config is explicit (base_url/token/project_id) — the SDK never reads
|
|
20
|
+
environment variables; the container thin-shell supplies env values.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, base_url: str, token: str, project_id: str):
|
|
24
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
25
|
+
self.token = token or ""
|
|
26
|
+
self.project_id = project_id or ""
|
|
27
|
+
|
|
28
|
+
def configured(self) -> bool:
|
|
29
|
+
return bool(self.base_url and self.token and self.project_id)
|
|
30
|
+
|
|
31
|
+
def _headers(self, *, json_body: bool) -> dict:
|
|
32
|
+
h = {"Authorization": f"Bearer {self.token}"}
|
|
33
|
+
if json_body:
|
|
34
|
+
h["Content-Type"] = "application/json"
|
|
35
|
+
return h
|
|
36
|
+
|
|
37
|
+
def _build_request(self, method: str, path: str, body) -> urllib.request.Request:
|
|
38
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
39
|
+
return urllib.request.Request(
|
|
40
|
+
f"{self.base_url}{path}",
|
|
41
|
+
data=data,
|
|
42
|
+
headers=self._headers(json_body=body is not None),
|
|
43
|
+
method=method,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _send(self, req, timeout):
|
|
47
|
+
"""Transport seam — tests override this; production uses urllib."""
|
|
48
|
+
return urllib.request.urlopen(req, timeout=timeout)
|
|
49
|
+
|
|
50
|
+
def _raise_http_error(self, method: str, path: str, exc: urllib.error.HTTPError):
|
|
51
|
+
detail = exc.read().decode(errors="replace") if exc.fp else ""
|
|
52
|
+
raise AllyBuildError(
|
|
53
|
+
exc.code, f"{method} {path} failed ({exc.code}): {detail}"
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
def request(self, method: str, path: str, *, body=None, timeout: float = 10):
|
|
57
|
+
req = self._build_request(method, path, body)
|
|
58
|
+
try:
|
|
59
|
+
with self._send(req, timeout) as resp:
|
|
60
|
+
raw = resp.read()
|
|
61
|
+
return json.loads(raw) if raw else None
|
|
62
|
+
except urllib.error.HTTPError as exc:
|
|
63
|
+
self._raise_http_error(method, path, exc)
|
|
64
|
+
|
|
65
|
+
def get(self, path: str, *, timeout: float = 10):
|
|
66
|
+
return self.request("GET", path, timeout=timeout)
|
|
67
|
+
|
|
68
|
+
def post(self, path: str, body=None, *, timeout: float = 10):
|
|
69
|
+
return self.request("POST", path, body=body, timeout=timeout)
|
|
70
|
+
|
|
71
|
+
def request_drain(self, method: str, path: str, *, body=None, timeout: float = 600) -> None:
|
|
72
|
+
"""Send a request and drain the (possibly SSE) response — output discarded."""
|
|
73
|
+
req = self._build_request(method, path, body)
|
|
74
|
+
try:
|
|
75
|
+
with self._send(req, timeout) as resp:
|
|
76
|
+
while resp.read(4096):
|
|
77
|
+
pass
|
|
78
|
+
except urllib.error.HTTPError as exc:
|
|
79
|
+
self._raise_http_error(method, path, exc)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""MCP JSON-RPC client — ported from runtime/environment/python/workflow.py."""
|
|
2
|
+
import json
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
from .client import AllyBuildClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class McpClient:
|
|
10
|
+
"""MCP client for calling tools on a single streamable-http server.
|
|
11
|
+
|
|
12
|
+
Implements JSON-RPC 2.0 over HTTP POST, handling both plain JSON and
|
|
13
|
+
SSE-streamed responses transparently.
|
|
14
|
+
|
|
15
|
+
Sync usage:
|
|
16
|
+
exa = mcp("exa")
|
|
17
|
+
result = exa.call("web_search_exa", {"query": "latest MCP news"})
|
|
18
|
+
for item in result["content"]:
|
|
19
|
+
if item["type"] == "text":
|
|
20
|
+
print(item["text"])
|
|
21
|
+
|
|
22
|
+
Async usage (inside an async def run):
|
|
23
|
+
import asyncio
|
|
24
|
+
r1, r2 = await asyncio.gather(
|
|
25
|
+
mcp("exa").acall("web_search_exa", {"query": "python 3.13"}),
|
|
26
|
+
mcp("context7").acall("resolve-library-id",
|
|
27
|
+
{"query": "fastapi", "libraryName": "fastapi"}),
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, name: str, url: str, headers: dict) -> None:
|
|
32
|
+
self._name = name
|
|
33
|
+
self._url = url
|
|
34
|
+
self._headers = headers
|
|
35
|
+
self._req_id = 0
|
|
36
|
+
|
|
37
|
+
def _send(self, req, timeout):
|
|
38
|
+
"""Transport seam — tests override this; production uses urllib."""
|
|
39
|
+
return urllib.request.urlopen(req, timeout=timeout)
|
|
40
|
+
|
|
41
|
+
def _rpc(self, method: str, params: dict, timeout: int = 60) -> dict:
|
|
42
|
+
"""Send a JSON-RPC 2.0 request and return the parsed response dict."""
|
|
43
|
+
self._req_id += 1
|
|
44
|
+
body = json.dumps({
|
|
45
|
+
"jsonrpc": "2.0",
|
|
46
|
+
"id": str(self._req_id),
|
|
47
|
+
"method": method,
|
|
48
|
+
"params": params,
|
|
49
|
+
}).encode()
|
|
50
|
+
|
|
51
|
+
req_headers = {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
"Accept": "application/json, text/event-stream",
|
|
54
|
+
**self._headers,
|
|
55
|
+
}
|
|
56
|
+
req = urllib.request.Request(
|
|
57
|
+
self._url, data=body, headers=req_headers, method="POST"
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
with self._send(req, timeout) as resp:
|
|
61
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
62
|
+
raw = resp.read()
|
|
63
|
+
# SSE stream: parse the first complete data: line
|
|
64
|
+
if "text/event-stream" in content_type:
|
|
65
|
+
text = raw.decode(errors="replace")
|
|
66
|
+
for line in text.splitlines():
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line.startswith("data: "):
|
|
69
|
+
continue
|
|
70
|
+
payload = line[6:].strip()
|
|
71
|
+
if payload in ("", "[DONE]"):
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
return json.loads(payload)
|
|
75
|
+
except ValueError:
|
|
76
|
+
continue
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"MCP [{self._name}]: no parseable data in SSE stream"
|
|
79
|
+
)
|
|
80
|
+
return json.loads(raw)
|
|
81
|
+
except urllib.error.HTTPError as exc:
|
|
82
|
+
detail = exc.read().decode(errors="replace") if exc.fp else ""
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"MCP [{self._name}] {method} failed (HTTP {exc.code}): {detail}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
def list_tools(self) -> list:
|
|
88
|
+
"""List all tools available on this MCP server.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of tool dicts, each with 'name', 'description', 'inputSchema'.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
for tool in mcp("exa").list_tools():
|
|
95
|
+
print(tool["name"], "-", tool["description"])
|
|
96
|
+
"""
|
|
97
|
+
resp = self._rpc("tools/list", {})
|
|
98
|
+
err = resp.get("error")
|
|
99
|
+
if err:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
f"MCP [{self._name}] tools/list error: {err.get('message', err)}"
|
|
102
|
+
)
|
|
103
|
+
return resp.get("result", {}).get("tools", [])
|
|
104
|
+
|
|
105
|
+
def call(self, tool_name: str, arguments: dict | None = None, timeout: int = 60) -> dict:
|
|
106
|
+
"""Call a tool on this MCP server.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
tool_name: Name of the tool to invoke.
|
|
110
|
+
arguments: Tool arguments dict (must match the tool's inputSchema).
|
|
111
|
+
timeout: Request timeout in seconds (default 60).
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Result dict with a 'content' list of {type, text} items and
|
|
115
|
+
an optional 'isError' flag. Most tools return a single text item.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
RuntimeError: Server returned a JSON-RPC error.
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
result = mcp("exa").call("web_search_exa", {"query": "Python 3.13 changes"})
|
|
122
|
+
print(result["content"][0]["text"])
|
|
123
|
+
"""
|
|
124
|
+
resp = self._rpc(
|
|
125
|
+
"tools/call",
|
|
126
|
+
{"name": tool_name, "arguments": arguments or {}},
|
|
127
|
+
timeout=timeout,
|
|
128
|
+
)
|
|
129
|
+
err = resp.get("error")
|
|
130
|
+
if err:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
f"MCP [{self._name}] tools/call '{tool_name}' error: "
|
|
133
|
+
f"{err.get('message', err)}"
|
|
134
|
+
)
|
|
135
|
+
return resp.get("result", {})
|
|
136
|
+
|
|
137
|
+
async def acall(
|
|
138
|
+
self, tool_name: str, arguments: dict | None = None, timeout: int = 60
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""Async version of call() — runs the HTTP request in a thread pool.
|
|
141
|
+
|
|
142
|
+
Use inside an ``async def run`` to fire multiple MCP calls concurrently
|
|
143
|
+
with ``asyncio.gather``.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
import asyncio
|
|
147
|
+
|
|
148
|
+
async def run(params, reporter):
|
|
149
|
+
r1, r2 = await asyncio.gather(
|
|
150
|
+
mcp("exa").acall("web_search_exa", {"query": "MCP news"}),
|
|
151
|
+
mcp("context7").acall("resolve-library-id",
|
|
152
|
+
{"query": "fastapi", "libraryName": "fastapi"}),
|
|
153
|
+
)
|
|
154
|
+
print(r1["content"][0]["text"])
|
|
155
|
+
"""
|
|
156
|
+
import asyncio
|
|
157
|
+
return await asyncio.to_thread(self.call, tool_name, arguments, timeout)
|
|
158
|
+
|
|
159
|
+
async def alist_tools(self) -> list:
|
|
160
|
+
"""Async version of list_tools() — runs in a thread pool.
|
|
161
|
+
|
|
162
|
+
Example:
|
|
163
|
+
tools = await mcp("exa").alist_tools()
|
|
164
|
+
"""
|
|
165
|
+
import asyncio
|
|
166
|
+
return await asyncio.to_thread(self.list_tools)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class McpApi:
|
|
170
|
+
"""MCP integration — resolve project server configs and call tools."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, client: AllyBuildClient):
|
|
173
|
+
self._client = client
|
|
174
|
+
self._cache: dict | None = None
|
|
175
|
+
|
|
176
|
+
def _fetch_mcp_config(self) -> dict:
|
|
177
|
+
"""Fetch project MCP server config from the AllyBuild API.
|
|
178
|
+
|
|
179
|
+
Returns {server_name: {"url": ..., "headers": {...}}} for all configured servers.
|
|
180
|
+
Returns {} on any error (no MCP servers configured, network failure, etc.).
|
|
181
|
+
"""
|
|
182
|
+
if not self._client.configured():
|
|
183
|
+
return {}
|
|
184
|
+
try:
|
|
185
|
+
project = self._client.get(f"/projects/{self._client.project_id}", timeout=5)
|
|
186
|
+
mcp_config = project.get("mcp_config") or {}
|
|
187
|
+
return mcp_config.get("mcpServers") or {}
|
|
188
|
+
except Exception:
|
|
189
|
+
return {}
|
|
190
|
+
|
|
191
|
+
def _mcp_config(self) -> dict:
|
|
192
|
+
"""Project MCP server config, fetched lazily on first mcp() call."""
|
|
193
|
+
if self._cache is None:
|
|
194
|
+
self._cache = self._fetch_mcp_config()
|
|
195
|
+
return self._cache
|
|
196
|
+
|
|
197
|
+
def mcp(self, server_name: str) -> McpClient:
|
|
198
|
+
"""Return an MCP client for the named server.
|
|
199
|
+
|
|
200
|
+
The server must be configured in the project's MCP integration settings
|
|
201
|
+
(integration tab → streamable-http servers).
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
server_name: Key in the project's mcpServers config (e.g. "exa").
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
McpClient with list_tools(), call(), alist_tools(), acall() methods.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
KeyError: Server not found in project MCP config.
|
|
211
|
+
ValueError: Server has no URL (stdio servers are not supported here).
|
|
212
|
+
|
|
213
|
+
Sync example:
|
|
214
|
+
result = mcp("exa").call("web_search_exa", {"query": "Python 3.13"})
|
|
215
|
+
print(result["content"][0]["text"])
|
|
216
|
+
|
|
217
|
+
Async example (inside async def run):
|
|
218
|
+
import asyncio
|
|
219
|
+
r1, r2 = await asyncio.gather(
|
|
220
|
+
mcp("exa").acall("web_search_exa", {"query": "Python 3.13"}),
|
|
221
|
+
mcp("context7").acall("resolve-library-id",
|
|
222
|
+
{"query": "fastapi", "libraryName": "fastapi"}),
|
|
223
|
+
)
|
|
224
|
+
"""
|
|
225
|
+
cfg_map = self._mcp_config()
|
|
226
|
+
if server_name not in cfg_map:
|
|
227
|
+
available = list(cfg_map.keys())
|
|
228
|
+
raise KeyError(
|
|
229
|
+
f"MCP server {server_name!r} not found in project config. "
|
|
230
|
+
f"Configured servers: {available}"
|
|
231
|
+
)
|
|
232
|
+
cfg = cfg_map[server_name]
|
|
233
|
+
url = cfg.get("url")
|
|
234
|
+
if not url:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"MCP server {server_name!r} has no URL. "
|
|
237
|
+
"Only streamable-http servers (with a URL) are supported in workflow scripts."
|
|
238
|
+
)
|
|
239
|
+
return McpClient(name=server_name, url=url, headers=cfg.get("headers") or {})
|
|
240
|
+
|
|
241
|
+
def mcp_call(
|
|
242
|
+
self,
|
|
243
|
+
server_name: str,
|
|
244
|
+
tool_name: str,
|
|
245
|
+
arguments: dict | None = None,
|
|
246
|
+
timeout: int = 60,
|
|
247
|
+
) -> dict:
|
|
248
|
+
"""Call an MCP tool in one line.
|
|
249
|
+
|
|
250
|
+
Shorthand for mcp(server_name).call(tool_name, arguments).
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
server_name: MCP server name (e.g. "exa", "context7").
|
|
254
|
+
tool_name: Tool to invoke.
|
|
255
|
+
arguments: Tool arguments dict.
|
|
256
|
+
timeout: Request timeout in seconds (default 60).
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Result dict with 'content' list.
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
result = mcp_call("exa", "web_search_exa", {"query": "MCP protocol 2025"})
|
|
263
|
+
print(result["content"][0]["text"])
|
|
264
|
+
|
|
265
|
+
result = mcp_call("context7", "resolve-library-id",
|
|
266
|
+
{"query": "sqlalchemy", "libraryName": "sqlalchemy"})
|
|
267
|
+
"""
|
|
268
|
+
return self.mcp(server_name).call(tool_name, arguments, timeout=timeout)
|
|
269
|
+
|
|
270
|
+
async def async_mcp_call(
|
|
271
|
+
self,
|
|
272
|
+
server_name: str,
|
|
273
|
+
tool_name: str,
|
|
274
|
+
arguments: dict | None = None,
|
|
275
|
+
timeout: int = 60,
|
|
276
|
+
) -> dict:
|
|
277
|
+
"""Async shorthand: await a single MCP tool call.
|
|
278
|
+
|
|
279
|
+
Equivalent to await mcp(server_name).acall(tool_name, arguments).
|
|
280
|
+
Runs the HTTP request in a thread pool so it doesn't block the event loop.
|
|
281
|
+
|
|
282
|
+
Example (inside async def run):
|
|
283
|
+
import asyncio
|
|
284
|
+
|
|
285
|
+
async def run(params, reporter):
|
|
286
|
+
# Sequential
|
|
287
|
+
result = await async_mcp_call("exa", "web_search_exa",
|
|
288
|
+
{"query": "MCP 2025"})
|
|
289
|
+
|
|
290
|
+
# Concurrent
|
|
291
|
+
r1, r2 = await asyncio.gather(
|
|
292
|
+
async_mcp_call("exa", "web_search_exa", {"query": "python"}),
|
|
293
|
+
async_mcp_call("context7", "resolve-library-id",
|
|
294
|
+
{"query": "fastapi", "libraryName": "fastapi"}),
|
|
295
|
+
)
|
|
296
|
+
print(r1["content"][0]["text"])
|
|
297
|
+
"""
|
|
298
|
+
return await self.mcp(server_name).acall(tool_name, arguments, timeout=timeout)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Memory queries — ported from runtime/environment/python/workflow.py."""
|
|
2
|
+
from .client import AllyBuildClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MemoriesApi:
|
|
6
|
+
"""List / search memories accumulated by meta-tasks and task runs."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, client: AllyBuildClient):
|
|
9
|
+
self._client = client
|
|
10
|
+
|
|
11
|
+
def _ensure(self) -> None:
|
|
12
|
+
if not self._client.configured():
|
|
13
|
+
raise RuntimeError("ALLYBUILD_API_URL / PROJECT_ID / TASK_JWT not set")
|
|
14
|
+
|
|
15
|
+
def list_task_type_memories(
|
|
16
|
+
self,
|
|
17
|
+
task_type: str,
|
|
18
|
+
query: str | None = None,
|
|
19
|
+
k: int = 20,
|
|
20
|
+
order: str = "most",
|
|
21
|
+
limit: int = 20,
|
|
22
|
+
offset: int = 0,
|
|
23
|
+
) -> list:
|
|
24
|
+
"""List or semantically search memories accumulated by a meta-task.
|
|
25
|
+
|
|
26
|
+
Without query: paginated listing ordered by creation time (limit/offset).
|
|
27
|
+
With query: semantic similarity search returning k results.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
task_type: Meta-task identifier string or UUID.
|
|
31
|
+
query: Semantic search query. When provided, switches to similarity
|
|
32
|
+
search and the limit/offset params are ignored.
|
|
33
|
+
k: Number of results for semantic search (default 20, max 100).
|
|
34
|
+
order: "most" — most relevant first (default).
|
|
35
|
+
"least" — least relevant first.
|
|
36
|
+
limit: Page size for paginated listing (default 20, max 100).
|
|
37
|
+
offset: Items to skip for paginated listing (default 0).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
List of memory dicts. Semantic results include a "score" field.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
# Paginated listing
|
|
44
|
+
for m in api.list_task_type_memories("summariser", limit=10):
|
|
45
|
+
print(m["memory"])
|
|
46
|
+
|
|
47
|
+
# Most relevant
|
|
48
|
+
hits = api.list_task_type_memories("summariser", query="authentication", k=5)
|
|
49
|
+
|
|
50
|
+
# Most novel / least similar to a known topic
|
|
51
|
+
novel = api.list_task_type_memories("summariser", query="authentication",
|
|
52
|
+
k=5, order="least")
|
|
53
|
+
"""
|
|
54
|
+
self._ensure()
|
|
55
|
+
if query and query.strip():
|
|
56
|
+
qs = f"query={query.strip()}&k={k}&order={order}"
|
|
57
|
+
else:
|
|
58
|
+
qs = f"limit={limit}&offset={offset}"
|
|
59
|
+
result = self._client.get(
|
|
60
|
+
f"/projects/{self._client.project_id}/task-types/{task_type}/memories?{qs}"
|
|
61
|
+
)
|
|
62
|
+
return result.get("memories", result) if isinstance(result, dict) else result
|
|
63
|
+
|
|
64
|
+
def get_task_memories(
|
|
65
|
+
self,
|
|
66
|
+
task_id: str,
|
|
67
|
+
limit: int = 20,
|
|
68
|
+
offset: int = 0,
|
|
69
|
+
) -> list:
|
|
70
|
+
"""Get memories contributed by a single task run.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
task_id: UUID of the task instance.
|
|
74
|
+
limit: Page size (default 20, max 100).
|
|
75
|
+
offset: Memories to skip for pagination (default 0).
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of memory dicts.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
for m in api.get_task_memories(TASK_ID):
|
|
82
|
+
print(m["memory"])
|
|
83
|
+
"""
|
|
84
|
+
self._ensure()
|
|
85
|
+
result = self._client.get(
|
|
86
|
+
f"/projects/{self._client.project_id}/tasks/{task_id}/memories?limit={limit}&offset={offset}"
|
|
87
|
+
)
|
|
88
|
+
return result.get("memories", result) if isinstance(result, dict) else result
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""OKF knowledge-base operations — ported from runtime/environment/python/workflow.py."""
|
|
2
|
+
from .client import AllyBuildClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OkfApi:
|
|
6
|
+
"""Search and lint the project's OKF knowledge-base workspace."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, client: AllyBuildClient):
|
|
9
|
+
self._client = client
|
|
10
|
+
|
|
11
|
+
def _ensure(self) -> None:
|
|
12
|
+
if not self._client.configured():
|
|
13
|
+
raise RuntimeError("ALLYBUILD_API_URL / PROJECT_ID / TASK_JWT not set")
|
|
14
|
+
|
|
15
|
+
def search(self, query: str, workspace_id: str | None = None) -> list:
|
|
16
|
+
"""Search the project's knowledge-base workspace for OKF concepts.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
query: Search query string.
|
|
20
|
+
workspace_id: Knowledge-base workspace UUID. Defaults to the
|
|
21
|
+
project's knowledge-base workspace.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of concept summary dicts (title, description, concept_type, etc.).
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
hits = api.search("authentication flow")
|
|
28
|
+
for h in hits:
|
|
29
|
+
print(h["title"], "-", h.get("description", ""))
|
|
30
|
+
"""
|
|
31
|
+
self._ensure()
|
|
32
|
+
body: dict = {"query": query}
|
|
33
|
+
if workspace_id:
|
|
34
|
+
body["workspace_id"] = workspace_id
|
|
35
|
+
return self._client.post(f"/projects/{self._client.project_id}/workspace/okf/search", body)
|
|
36
|
+
|
|
37
|
+
def lint(self, workspace_id: str | None = None) -> list:
|
|
38
|
+
"""Run OKF health checks on the project's knowledge-base workspace.
|
|
39
|
+
|
|
40
|
+
Checks for broken links, orphan concepts, missing type/citations,
|
|
41
|
+
and duplicate titles.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
workspace_id: Knowledge-base workspace UUID. Defaults to the
|
|
45
|
+
project's knowledge-base workspace.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of issue dicts with issue_type, concept_id, message.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
issues = api.lint()
|
|
52
|
+
for issue in issues:
|
|
53
|
+
print(f"[{issue['issue_type']}] {issue['concept_id']}: {issue['message']}")
|
|
54
|
+
"""
|
|
55
|
+
self._ensure()
|
|
56
|
+
body: dict = {}
|
|
57
|
+
if workspace_id:
|
|
58
|
+
body["workspace_id"] = workspace_id
|
|
59
|
+
return self._client.post(f"/projects/{self._client.project_id}/workspace/okf/lint", body)
|