@roll-agent/octopus-agent 0.1.3 → 0.2.0
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.
- package/README.md +26 -190
- package/SKILL.md +27 -103
- package/package.json +12 -18
- package/references/env.yaml +1 -22
- package/scripts/start-octopus-agent.js +1 -125
- package/src/config.js +13 -0
- package/src/context.js +5 -0
- package/src/mcp-client.js +80 -0
- package/src/orchestrator.js +57 -0
- package/src/server-core.js +67 -0
- package/src/server.js +10 -0
- package/src/stdio.js +29 -0
- package/octopus_skill/__init__.py +0 -11
- package/pyproject.toml +0 -19
- package/scripts/refresh_dictionary.py +0 -67
- package/scripts/verify_binding.py +0 -47
- package/src/octopus_skill/__init__.py +0 -5
- package/src/octopus_skill/__main__.py +0 -4
- package/src/octopus_skill/_version.py +0 -3
- package/src/octopus_skill/agent.py +0 -5739
- package/src/octopus_skill/audit_logger.py +0 -29
- package/src/octopus_skill/config.py +0 -129
- package/src/octopus_skill/context.py +0 -17
- package/src/octopus_skill/device_info.py +0 -7
- package/src/octopus_skill/exporter.py +0 -66
- package/src/octopus_skill/llm_result_renderer.py +0 -101
- package/src/octopus_skill/llm_sql_generator.py +0 -197
- package/src/octopus_skill/main.py +0 -26
- package/src/octopus_skill/mcp_client.py +0 -114
- package/src/octopus_skill/octopus_run.py +0 -755
- package/src/octopus_skill/prompt_builder.py +0 -522
- package/src/octopus_skill/question_analyzer.py +0 -401
- package/src/octopus_skill/result_renderer.py +0 -367
- package/src/octopus_skill/schema_cache.py +0 -49
- package/src/octopus_skill/schema_context_retriever.py +0 -1053
- package/src/octopus_skill/sql_generator.py +0 -2866
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
2
|
+
|
|
3
|
+
export class SpongeMcpError extends Error {
|
|
4
|
+
constructor(message, options = {}) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = "SpongeMcpError";
|
|
7
|
+
this.status = options.status;
|
|
8
|
+
this.code = options.code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class SpongeMcpClient {
|
|
13
|
+
constructor({ baseUrl, accessToken, requestTimeoutMs = 30_000, networkRetries = 2, fetchImpl = fetch }) {
|
|
14
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
15
|
+
this.accessToken = accessToken;
|
|
16
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
17
|
+
this.networkRetries = networkRetries;
|
|
18
|
+
this.fetchImpl = fetchImpl;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async call(tool, payload, { signal } = {}) {
|
|
22
|
+
let lastError;
|
|
23
|
+
for (let attempt = 0; attempt <= this.networkRetries; attempt += 1) {
|
|
24
|
+
try {
|
|
25
|
+
return await this.#post(tool, payload, signal);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
lastError = error;
|
|
28
|
+
if (attempt === this.networkRetries || !isRetryable(error)) throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw lastError;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async #post(tool, payload, parentSignal) {
|
|
35
|
+
const timeout = AbortSignal.timeout(this.requestTimeoutMs);
|
|
36
|
+
const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout;
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await this.fetchImpl(`${this.baseUrl}/mcp/tools/${tool}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"content-type": "application/json",
|
|
43
|
+
...(this.accessToken ? { authorization: `Bearer ${this.accessToken}` } : {}),
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(payload),
|
|
46
|
+
signal,
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new SpongeMcpError(`MCP ${tool} 网络调用失败`, { cause: error, code: "NETWORK_ERROR" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const text = await response.text();
|
|
53
|
+
let body;
|
|
54
|
+
try {
|
|
55
|
+
body = text ? JSON.parse(text) : {};
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new SpongeMcpError(`MCP ${tool} 返回非 JSON 数据`, {
|
|
58
|
+
cause: error,
|
|
59
|
+
status: response.status,
|
|
60
|
+
code: "INVALID_RESPONSE",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const detail = body?.error?.message;
|
|
65
|
+
throw new SpongeMcpError(
|
|
66
|
+
detail || `MCP ${tool} 调用失败:HTTP ${response.status}`,
|
|
67
|
+
{
|
|
68
|
+
status: response.status,
|
|
69
|
+
code: body?.errorCode || body?.error?.code || "HTTP_ERROR",
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return body;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRetryable(error) {
|
|
78
|
+
return error instanceof SpongeMcpError &&
|
|
79
|
+
(error.code === "NETWORK_ERROR" || RETRYABLE_STATUS.has(error.status));
|
|
80
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createRequestId } from "./context.js";
|
|
2
|
+
|
|
3
|
+
const RESULT_FORMATS = new Set(["text", "json", "yaml", "md", "markdown", "html"]);
|
|
4
|
+
|
|
5
|
+
export class ProtocolError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ProtocolError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class QueryOrchestrator {
|
|
13
|
+
constructor({ client, totalTimeoutMs = 59_000 }) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
this.totalTimeoutMs = totalTimeoutMs;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async query(input) {
|
|
19
|
+
validateInput(input);
|
|
20
|
+
const requestId = createRequestId();
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(new Error("查询总超时")), this.totalTimeoutMs);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await this.client.call("query_sponge", {
|
|
26
|
+
requestId,
|
|
27
|
+
question: input.question,
|
|
28
|
+
resultFormat: input.resultFormat || "markdown",
|
|
29
|
+
}, { signal: controller.signal });
|
|
30
|
+
return validateResponse(response, requestId);
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateInput(input) {
|
|
38
|
+
if (!input || typeof input.question !== "string" || !input.question.trim()) {
|
|
39
|
+
throw new TypeError("question 必须是非空字符串");
|
|
40
|
+
}
|
|
41
|
+
if (input.resultFormat !== undefined && !RESULT_FORMATS.has(input.resultFormat)) {
|
|
42
|
+
throw new TypeError("resultFormat 仅支持 text/json/yaml/md/markdown/html");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateResponse(response, requestId) {
|
|
47
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
|
48
|
+
throw new ProtocolError("query_sponge 返回值必须是对象");
|
|
49
|
+
}
|
|
50
|
+
if (response.requestId !== requestId) throw new ProtocolError("query_sponge 返回的 requestId 不一致");
|
|
51
|
+
if (response.status !== "succeeded") throw new ProtocolError("query_sponge 返回的 status 非法");
|
|
52
|
+
if (typeof response.queryId !== "string" || !response.queryId) {
|
|
53
|
+
throw new ProtocolError("query_sponge 缺少 queryId");
|
|
54
|
+
}
|
|
55
|
+
if (typeof response.answer !== "string") throw new ProtocolError("query_sponge 缺少 answer");
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const PROTOCOL_VERSION = "2024-11-05";
|
|
2
|
+
|
|
3
|
+
export function toolDefinitions() {
|
|
4
|
+
return [{
|
|
5
|
+
name: "query",
|
|
6
|
+
description: "仅用于用户明确提出的业务数据查询。介绍丸子、询问功能或使用方法时禁止调用。查询时原样传入问题,只调用一次 query_sponge(查询 Sponge)。最终回复必须完全等于 content[0].text,保持查询实体、筛选条件、业务逻辑、查询结果的顺序和 Markdown 表格,禁止总结、删减、改写或重排。",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
required: ["question"],
|
|
10
|
+
properties: {
|
|
11
|
+
question: { type: "string", description: "用户原始问题,不得改写" },
|
|
12
|
+
resultFormat: {
|
|
13
|
+
type: "string",
|
|
14
|
+
enum: ["text", "json", "yaml", "md", "markdown", "html"],
|
|
15
|
+
description: "结果格式,默认 markdown(Markdown 格式)",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
},
|
|
20
|
+
}];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function handleRequest(message, orchestrator) {
|
|
24
|
+
if (!message || typeof message !== "object" || !("id" in message)) return null;
|
|
25
|
+
const { id, method } = message;
|
|
26
|
+
try {
|
|
27
|
+
if (method === "initialize") {
|
|
28
|
+
return ok(id, {
|
|
29
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
30
|
+
capabilities: { tools: {} },
|
|
31
|
+
serverInfo: { name: "octopus-agent", version: "0.2.0" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (method === "ping") return ok(id, {});
|
|
35
|
+
if (method === "tools/list") return ok(id, { tools: toolDefinitions() });
|
|
36
|
+
if (method === "tools/call") {
|
|
37
|
+
if (message.params?.name !== "query") return toolError(id, "未知 Tool");
|
|
38
|
+
const result = await orchestrator.query(message.params?.arguments || {});
|
|
39
|
+
return ok(id, {
|
|
40
|
+
content: [{
|
|
41
|
+
type: "text",
|
|
42
|
+
text: result.answer,
|
|
43
|
+
annotations: { audience: ["user"], priority: 1 },
|
|
44
|
+
}],
|
|
45
|
+
structuredContent: {
|
|
46
|
+
queryId: result.queryId,
|
|
47
|
+
requestId: result.requestId,
|
|
48
|
+
status: result.status,
|
|
49
|
+
rowCount: result.rowCount,
|
|
50
|
+
repairCount: result.repairCount,
|
|
51
|
+
totalDurationSeconds: result.totalDurationSeconds,
|
|
52
|
+
},
|
|
53
|
+
isError: result.status !== "succeeded",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return fail(id, -32601, `Method not found: ${method}`);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return toolError(id, error instanceof Error ? error.message : String(error));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
|
|
63
|
+
const fail = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
|
|
64
|
+
const toolError = (id, message) => ok(id, {
|
|
65
|
+
content: [{ type: "text", text: message }],
|
|
66
|
+
isError: true,
|
|
67
|
+
});
|
package/src/server.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { SpongeMcpClient } from "./mcp-client.js";
|
|
3
|
+
import { QueryOrchestrator } from "./orchestrator.js";
|
|
4
|
+
import { handleRequest } from "./server-core.js";
|
|
5
|
+
import { StdioTransport } from "./stdio.js";
|
|
6
|
+
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const client = new SpongeMcpClient(config);
|
|
9
|
+
const orchestrator = new QueryOrchestrator({ client, totalTimeoutMs: config.totalTimeoutMs });
|
|
10
|
+
new StdioTransport(process.stdin, process.stdout, (message) => handleRequest(message, orchestrator)).start();
|
package/src/stdio.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
|
|
3
|
+
export class StdioTransport {
|
|
4
|
+
constructor(input, output, onMessage) {
|
|
5
|
+
this.input = input;
|
|
6
|
+
this.output = output;
|
|
7
|
+
this.onMessage = onMessage;
|
|
8
|
+
this.queue = Promise.resolve();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
start() {
|
|
12
|
+
const lines = createInterface({ input: this.input, crlfDelay: Infinity });
|
|
13
|
+
lines.on("line", (line) => {
|
|
14
|
+
if (!line.trim()) return;
|
|
15
|
+
this.queue = this.queue
|
|
16
|
+
.then(async () => {
|
|
17
|
+
const response = await this.onMessage(JSON.parse(line));
|
|
18
|
+
if (response) this.send(response);
|
|
19
|
+
})
|
|
20
|
+
.catch((error) => {
|
|
21
|
+
process.stderr.write(`octopus-agent stdio error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
send(message) {
|
|
27
|
+
this.output.write(`${JSON.stringify(message)}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
_src_package = Path(__file__).resolve().parent.parent / "src" / "octopus_skill"
|
|
6
|
-
if _src_package.exists():
|
|
7
|
-
__path__.append(str(_src_package))
|
|
8
|
-
|
|
9
|
-
from ._version import __version__
|
|
10
|
-
|
|
11
|
-
__all__ = ["__version__"]
|
package/pyproject.toml
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "octopus-skill"
|
|
3
|
-
version = "0.1.3"
|
|
4
|
-
description = "丸子Agent(Octopus Agent)"
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.11"
|
|
7
|
-
dependencies = [
|
|
8
|
-
"rapidfuzz>=3.0",
|
|
9
|
-
]
|
|
10
|
-
|
|
11
|
-
[project.scripts]
|
|
12
|
-
octopus-agent = "octopus_skill.main:main"
|
|
13
|
-
|
|
14
|
-
[build-system]
|
|
15
|
-
requires = ["setuptools>=68"]
|
|
16
|
-
build-backend = "setuptools.build_meta"
|
|
17
|
-
|
|
18
|
-
[tool.setuptools.packages.find]
|
|
19
|
-
where = ["src"]
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""One-off: refresh the entity dictionary cache via the MCP server.
|
|
2
|
-
|
|
3
|
-
Replicates Agent._ensure_dictionary's refresh path: calls get_entity_dictionary
|
|
4
|
-
with the configured entity_types and writes the snapshot to the cache file.
|
|
5
|
-
"""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import sys
|
|
9
|
-
|
|
10
|
-
from octopus_skill.config import load_config
|
|
11
|
-
from octopus_skill.context import new_trace_id
|
|
12
|
-
from octopus_skill.entity_dictionary import EntityDictionaryCache
|
|
13
|
-
from octopus_skill.mcp_client import SpongeMcpClient
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def main() -> int:
|
|
17
|
-
config = load_config()
|
|
18
|
-
if config.dictionary is None:
|
|
19
|
-
print("dictionary config is None; nothing to refresh")
|
|
20
|
-
return 1
|
|
21
|
-
|
|
22
|
-
client = SpongeMcpClient(
|
|
23
|
-
base_url=config.sponge_mcp.base_url,
|
|
24
|
-
access_token=config.sponge_mcp.access_token,
|
|
25
|
-
timeout_ms=config.sponge_mcp.timeout_ms,
|
|
26
|
-
)
|
|
27
|
-
cache = EntityDictionaryCache(config.dictionary.cache_path)
|
|
28
|
-
cached = cache.load()
|
|
29
|
-
cached_version = (
|
|
30
|
-
str(cached.get("dictVersion")) if cached and cached.get("dictVersion") else None
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
print(f"base_url={config.sponge_mcp.base_url}")
|
|
34
|
-
print(f"token_set={bool(config.sponge_mcp.access_token)}")
|
|
35
|
-
print(f"cached_version={cached_version}")
|
|
36
|
-
print(f"entity_types={config.dictionary.entity_types}")
|
|
37
|
-
|
|
38
|
-
trace_id = new_trace_id("trace_dict")
|
|
39
|
-
response = client.get_entity_dictionary(
|
|
40
|
-
trace_id=trace_id,
|
|
41
|
-
dict_version=cached_version,
|
|
42
|
-
entity_types=config.dictionary.entity_types,
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
changed = response.get("changed")
|
|
46
|
-
print(f"response changed={changed} dictVersion={response.get('dictVersion')}")
|
|
47
|
-
|
|
48
|
-
if changed is False and cached is not None:
|
|
49
|
-
print("server reports no change; cache kept as-is")
|
|
50
|
-
return 0
|
|
51
|
-
|
|
52
|
-
if "entities" in response:
|
|
53
|
-
cache.save(response)
|
|
54
|
-
ent = response["entities"]
|
|
55
|
-
for key, value in ent.items():
|
|
56
|
-
items = value.get("items") if isinstance(value, dict) else None
|
|
57
|
-
n = len(items) if isinstance(items, list) else "n/a"
|
|
58
|
-
print(f" saved {key}: items={n}")
|
|
59
|
-
print(f"cache written to {config.dictionary.cache_path}")
|
|
60
|
-
return 0
|
|
61
|
-
|
|
62
|
-
print("response had no 'entities'; cache not updated")
|
|
63
|
-
return 2
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if __name__ == "__main__":
|
|
67
|
-
sys.exit(main())
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""One-off: verify the brand/location/project binding chain end-to-end.
|
|
2
|
-
|
|
3
|
-
Builds the resolver from the persisted dictionary cache (same path the agent
|
|
4
|
-
uses) and runs bind_entities on a few real questions, printing what resolved.
|
|
5
|
-
"""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
|
|
10
|
-
from octopus_skill.config import load_config
|
|
11
|
-
from octopus_skill.entity_binding import bind_entities
|
|
12
|
-
from octopus_skill.entity_resolver import EntityResolver, load_geo_aliases
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def show(question: str, resolver: EntityResolver) -> None:
|
|
16
|
-
bindings = bind_entities(question, resolver)
|
|
17
|
-
print(f"\nQ: {question}")
|
|
18
|
-
print(" brands :", [(e.id, e.canonical, e.matched_via) for e in bindings.brands])
|
|
19
|
-
print(" projects:", [(e.id, e.canonical, e.matched_via) for e in bindings.projects])
|
|
20
|
-
print(
|
|
21
|
-
" location:",
|
|
22
|
-
{lvl: (e.id, e.canonical) for lvl, e in bindings.location.items()},
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def main() -> int:
|
|
27
|
-
config = load_config()
|
|
28
|
-
assert config.dictionary is not None
|
|
29
|
-
dictionary = json.loads(config.dictionary.cache_path.read_text(encoding="utf-8"))
|
|
30
|
-
geo = load_geo_aliases(config.dictionary.geo_alias_path)
|
|
31
|
-
resolver = EntityResolver.from_payload(dictionary, geo_aliases=geo)
|
|
32
|
-
|
|
33
|
-
print("dictVersion:", dictionary.get("dictVersion"))
|
|
34
|
-
for t in ("brand", "project", "city", "region", "province"):
|
|
35
|
-
idx = resolver.indexes.get(t)
|
|
36
|
-
print(f" index {t}: {len(idx.items) if idx else 0} items")
|
|
37
|
-
|
|
38
|
-
# Real project names pulled from the refreshed dictionary.
|
|
39
|
-
show("西贝莜面村这个项目在上海有多少在招岗位", resolver)
|
|
40
|
-
show("查一下哈根达斯项目的情况", resolver)
|
|
41
|
-
show("北京肯德基项目最近招聘多少人", resolver)
|
|
42
|
-
show("CoCo都可茶饮在南京宝山的门店", resolver)
|
|
43
|
-
return 0
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if __name__ == "__main__":
|
|
47
|
-
raise SystemExit(main())
|