diff --git a/README.md b/README.md index 5a1ac50be83fb8ca1e0a9a390b1d321b162f2b17..190da84dc2d68928f99e0ff24bcc79df8904814e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ docker volume create daedalus-agent-data docker run -d --restart unless-stopped --name daedalus-agent --cap-add NET_ADMIN -p 3030-3050:3030-3050 -p 1883:1883 -p 9000:9000 -v daedalus-agent-data:/var/daedalus-agent crpi-4auaoyyj6r36p6lb.cn-hangzhou.personal.cr.aliyuncs.com/huozige_lab/daedalus-agent-x64:[版本号] ``` -`[版本号]` 请参考项目 Release,例如 `20260702.01`。运行数据保存在 Docker volume `daedalus-agent-data` 中;如需隔离多套环境,请替换为新的 volume 名称。详细说明见 [docker/README.md](./docker/README.md)。 +`[版本号]` 请参考项目 Release,例如 `20260804.01`。运行数据保存在 Docker volume `daedalus-agent-data` 中;如需隔离多套环境,请替换为新的 volume 名称。详细说明见 [docker/README.md](./docker/README.md)。 ## 步骤2:登录管理控制台 @@ -77,7 +77,7 @@ Ontology zip 需通过 `huozige-ontology-builder` 构建,[点击阅读使用 - 每日 Token 限额 - 授权用户 -创建完成后,点击 `授权用户` 按钮,将改数字员工分配和活字格管理控制台上的用户。此处需要输入的是用户名,而不是用户全名。 +创建完成后,点击 `管理用户` 按钮,将该数字员工分配和活字格管理控制台上的用户。此处需要输入的是用户名,而不是用户全名。 ## 步骤6:对接对话应用 @@ -156,4 +156,4 @@ MQTT/MinIO 密码可在 `系统设置` 页面中 `基础设置` 选项卡获取 1. 在管理控制台的【系统设置】页面中【系统日志下载】选项卡,下载当前日期的 `交互日志`,检查是否存在 `"direction":"IN"` 的入站日志和 `"direction":"OUT"` 的出站日志。 2. 如果没有进站日志,则检查对话应用全局配置中的 `DA_MQTT_CHANNEL_USERNAME` 和 `DA_MQTT_CHANNEL_PASSWORD`;如果有入站日志,但没有出站日志,则需要在管理控制台的【数字员工】页面中,点击该员工,通过右上角的【对话测试】,检查大语言模型配置是否正确。 -3. 如果存在进出站日志,请检查对话应用全局配置中的 `MQTT_TOPIC_FILTER`。 \ No newline at end of file +3. 如果存在进出站日志,请检查对话应用全局配置中的 `MQTT_TOPIC_FILTER`。 diff --git a/docker/Dockerfile b/docker/Dockerfile index dae418001f59b39496efd126d837bf8f0239fa40..00b6269e0d0707501d294c3aca2a64c71038d249 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -524,7 +524,7 @@ RUN --mount=type=bind,source=docker/assets/workspace-templates/default,target=/t if [[ -d /tmp/default-template ]]; then \ cp -a /tmp/default-template/. "${workspace_template}/"; \ fi; \ - for doc in AGENTS.md USER.md SOUL.md; do \ + for doc in AGENTS.md USER.md SOUL.md HEARTBEAT.md; do \ if [[ -f "${workspace_template}/${doc}" ]]; then \ cp "${workspace_template}/${doc}" "${seed_workspace}/${doc}"; \ fi; \ diff --git a/docker/assets/config.json b/docker/assets/config.json index 404d4f887c6caf72785901c0248249f432d69591..3a6c0d35edf156fb198cd147f8d1f29d7ddd128a 100644 --- a/docker/assets/config.json +++ b/docker/assets/config.json @@ -2,7 +2,11 @@ "agents": { "defaults": { "workspace": "/var/daedalus-agent/.openclaw/workspaces/default", - "skipBootstrap": true + "skipBootstrap": true, + "heartbeat": { + "isolatedSession": true, + "lightContext": true + } } }, "gateway": { diff --git a/docker/assets/scripts/kernel-startup.sh b/docker/assets/scripts/kernel-startup.sh index e0eb41afb3bc40d5a40c591750089f58fbae628a..ca880419740aa2ffae31c7e68ec599d21486b3cc 100644 --- a/docker/assets/scripts/kernel-startup.sh +++ b/docker/assets/scripts/kernel-startup.sh @@ -2207,6 +2207,8 @@ patch_openclaw_config() { .agents.defaults.workspace = $workspace | .agents.defaults.maxConcurrent = $gatewayMaxConcurrent | .agents.defaults.skipBootstrap = true + | .agents.defaults.heartbeat.isolatedSession = true + | .agents.defaults.heartbeat.lightContext = true | .gateway.mode = "local" | .gateway.auth.mode = "token" | .gateway.auth.token = $gatewayAuthToken diff --git a/docker/assets/scripts/startup.sh b/docker/assets/scripts/startup.sh index d68bb9b3ac76f72392db01d84b8b5c09650ab6ac..237ea9ae6ad3dbf85d7c4398ab4bb4c5bccfcae1 100644 --- a/docker/assets/scripts/startup.sh +++ b/docker/assets/scripts/startup.sh @@ -755,6 +755,11 @@ const shared = { config.agents = config.agents || {}; config.agents.defaults = config.agents.defaults || {}; config.agents.defaults.workspace = path.posix.join(productRoot, ".openclaw/workspaces/default"); +config.agents.defaults.heartbeat = { + ...(config.agents.defaults.heartbeat || {}), + isolatedSession: true, + lightContext: true +}; config.memory = config.memory || {}; config.memory.backend = "qmd"; config.memory.qmd = { diff --git a/docker/assets/workspace-templates/default/HEARTBEAT.md b/docker/assets/workspace-templates/default/HEARTBEAT.md new file mode 100644 index 0000000000000000000000000000000000000000..0eb4760f0eaa6e83cec35840458d5d5aaced9592 --- /dev/null +++ b/docker/assets/workspace-templates/default/HEARTBEAT.md @@ -0,0 +1,2 @@ +# Keep this file empty (or with only comments) to skip heartbeat API calls. +# Add tasks below when you want the agent to check something periodically. diff --git a/extensions/clawhub-skills/daedalus-agent-call-app-service/references/invoke-rules.md b/extensions/clawhub-skills/daedalus-agent-call-app-service/references/invoke-rules.md index 25e1032c3264f62d8f71e93d0f0728b67b459cbd..082867583d4f754673886ae5232247e41332027f 100644 --- a/extensions/clawhub-skills/daedalus-agent-call-app-service/references/invoke-rules.md +++ b/extensions/clawhub-skills/daedalus-agent-call-app-service/references/invoke-rules.md @@ -121,6 +121,13 @@ daedalus-agent-apps-invoke-cli status - `target-page` - `page-limit-row-count` - `columns` 保持 ontology 文档中的原顺序,不要重排 +- 执行 CLI 前必须完成结构预检: + - `jsonBody` 必须是对象 + - `columns` 必须是非空数组;即使只有一列也不能写成单个对象 + - 每个 `columns` 元素必须是对象,且 `column-name`、`guid` 都是非空字符串 + - `table-name`、`view-name`、`list-view-location`、`page-name` 都是非空字符串 + - `target-page`、`page-limit-row-count` 都是数字 +- 任一预检失败时不要执行 CLI,明确指出缺失或类型错误的字段 ### TableBinding 到底层 HTTP body 的映射说明 @@ -142,6 +149,7 @@ daedalus-agent-apps-invoke-cli status - `orderByInfo=null` - `columnFilterQueries=null` - `totalRowBindingInfos=[]` +- 不要把包含 `bindingInfos`、`currentRowInfo` 或 `offsetConditionInfo` 的底层 HTTP body 作为 CLI 的 `jsonBody`;CLI 只接受上面的兼容 schema ### CandidatesBinding 兼容 schema diff --git a/packages/daedalus-agent-apps-invoke-cli/README.md b/packages/daedalus-agent-apps-invoke-cli/README.md index bcebf9e118d9e2c15e035daab15f686fce8fa853..372ebe34212e3d6287af0ab28d69998a94b3c741 100644 --- a/packages/daedalus-agent-apps-invoke-cli/README.md +++ b/packages/daedalus-agent-apps-invoke-cli/README.md @@ -91,6 +91,27 @@ daedalus-agent-apps-invoke-cli binding OASystem GetTableDataWithOffset POST --bo CLI 会把 `TableBinding`、`CandidatesBinding`、`DataSourceBinding` 等别名规范化为上述命令。`GetMetadata` 和 `GetMetadata2` 只作为内部辅助能力存在,service 会拒绝直接调用。 +`GetTableDataWithOffset` 的请求体必须使用 SDK 兼容结构,其中 `columns` 即使只有一列也必须是非空数组: + +```json +{ + "columns": [ + { + "column-name": "编号", + "guid": "3261feec-650a-421c-b05f-7ea8b1bed3e5" + } + ], + "table-name": "物品表", + "view-name": "物品_库存物品信息表格", + "list-view-location": "物品_库存|物品信息表格", + "page-name": "物品_库存", + "target-page": 1, + "page-limit-row-count": 0 +} +``` + +service 会在调用 SDK 前验证该结构;缺少 `columns`、传入非数组或列项缺少 `column-name` / `guid` 时会返回明确的 400 错误。 + ## Socket 协议 Client 会向内部固定 socket `/var/daedalus-agent/run/app-invoke.sock` 写入一行 JSON 请求,service 返回一行 JSON 响应。 diff --git a/packages/daedalus-agent-apps-invoke-cli/src/proxy/hzg-provider-client.ts b/packages/daedalus-agent-apps-invoke-cli/src/proxy/hzg-provider-client.ts index cd62d0c59319214b3081acd39e7b5a620fdfdc6c..9276f01dc5cb23eba65d006d4ec8614981c9110d 100644 --- a/packages/daedalus-agent-apps-invoke-cli/src/proxy/hzg-provider-client.ts +++ b/packages/daedalus-agent-apps-invoke-cli/src/proxy/hzg-provider-client.ts @@ -93,6 +93,52 @@ function readRequiredString(record: JsonObject, key: string, label: string): str return value.trim(); } +function readRequiredNumber(record: JsonObject, key: string, label: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw badRequest(`${label} must be a number`); + } + + return value; +} + +export function normalizeTableBindingBody(body: unknown): JsonObject { + if (!isJsonObject(body)) { + throw badRequest("GetTableDataWithOffset body must be a JSON object"); + } + + const normalized: JsonObject = { ...body }; + const columns = normalized.columns; + if (!Array.isArray(columns) || columns.length === 0) { + throw badRequest("GetTableDataWithOffset body.columns must be a non-empty array"); + } + + normalized.columns = columns.map((column, index) => { + if (!isJsonObject(column)) { + throw badRequest(`GetTableDataWithOffset body.columns[${index}] must be a JSON object`); + } + + return { + ...column, + "column-name": readRequiredString(column, "column-name", `GetTableDataWithOffset body.columns[${index}].column-name`), + guid: readRequiredString(column, "guid", `GetTableDataWithOffset body.columns[${index}].guid`) + }; + }); + + normalized["table-name"] = readRequiredString(normalized, "table-name", "GetTableDataWithOffset body.table-name"); + normalized["view-name"] = readRequiredString(normalized, "view-name", "GetTableDataWithOffset body.view-name"); + normalized["list-view-location"] = readRequiredString(normalized, "list-view-location", "GetTableDataWithOffset body.list-view-location"); + normalized["page-name"] = readRequiredString(normalized, "page-name", "GetTableDataWithOffset body.page-name"); + normalized["target-page"] = readRequiredNumber(normalized, "target-page", "GetTableDataWithOffset body.target-page"); + normalized["page-limit-row-count"] = readRequiredNumber( + normalized, + "page-limit-row-count", + "GetTableDataWithOffset body.page-limit-row-count" + ); + + return normalized; +} + function normalizeCalcBindingDataSourceBody(body: unknown): JsonObject { if (!isJsonObject(body)) { throw badRequest("CalcBindingDataSource body must be a JSON object"); @@ -217,8 +263,9 @@ export class HzgProviderClient { } if (command === "binding" && commandName === "GetTableDataWithOffset") { + const tableBindingBody = normalizeTableBindingBody(body); const result = await invokeWithCallback((callback) => - callGetTableDataWithOffsetWithCookie(system.service_endpoint, body, cookie, callback) + callGetTableDataWithOffsetWithCookie(system.service_endpoint, tableBindingBody, cookie, callback) ); return this.normalizeResult(result, { applicationName, commandName }); } diff --git a/packages/daedalus-agent-apps-invoke-cli/test/proxy/hzg-provider-client.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/proxy/hzg-provider-client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3eb9aa232dee865745a88ca07e58fe7f9ae41c70 --- /dev/null +++ b/packages/daedalus-agent-apps-invoke-cli/test/proxy/hzg-provider-client.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; + +import { ServiceError } from "../../src/proxy/errors.js"; +import { HzgProviderClient, normalizeTableBindingBody } from "../../src/proxy/hzg-provider-client.js"; +import type { BusinessSystemRecord } from "../../src/proxy/types.js"; + +function assertBadRequest(action: () => unknown, expectedMessage: string): void { + assert.throws(action, (error: unknown) => { + assert.ok(error instanceof ServiceError); + assert.equal(error.statusCode, 400); + assert.equal(error.message, expectedMessage); + return true; + }); +} + +test("table binding rejects a missing columns array before the SDK uses map", () => { + assertBadRequest( + () => normalizeTableBindingBody({ "table-name": "物品表" }), + "GetTableDataWithOffset body.columns must be a non-empty array" + ); +}); + +test("table binding rejects columns that are not an array", () => { + assertBadRequest( + () => normalizeTableBindingBody({ columns: { "column-name": "编号", guid: "column-1" } }), + "GetTableDataWithOffset body.columns must be a non-empty array" + ); +}); + +test("table binding rejects a column without a guid", () => { + assertBadRequest( + () => normalizeTableBindingBody({ columns: [{ "column-name": "编号" }] }), + "GetTableDataWithOffset body.columns[0].guid is required" + ); +}); + +test("table binding normalizes the complete SDK-compatible schema", () => { + const body = { + columns: [{ "column-name": " 编号 ", guid: " column-1 " }], + "table-name": " 物品表 ", + "view-name": " 物品列表 ", + "list-view-location": " 物品页|物品列表 ", + "page-name": " 物品页 ", + "target-page": 1, + "page-limit-row-count": 0 + }; + + expect(normalizeTableBindingBody(body)).toEqual({ + columns: [{ "column-name": "编号", guid: "column-1" }], + "table-name": "物品表", + "view-name": "物品列表", + "list-view-location": "物品页|物品列表", + "page-name": "物品页", + "target-page": 1, + "page-limit-row-count": 0 + }); + expect(body.columns[0]["column-name"]).toBe(" 编号 "); +}); + +test("provider rejects an invalid table binding before invoking the SDK", async () => { + const client = new HzgProviderClient({ + webApiClient: { + async getExternalCookie() { + return { cookie: "ForguncyServer=test" }; + } + } + }); + const system: BusinessSystemRecord = { + system_id: "system-1", + provider: "hzg", + application_name: "OASystem", + service_endpoint: "https://example.com/app", + session_endpoint: "https://example.com/session", + status: "active" + }; + + await assert.rejects( + () => client.execute(system, { + traceId: "trace-1", + command: "binding", + sessionId: "session-1", + parameters: { + applicationName: "OASystem", + commandName: "GetTableDataWithOffset", + method: "POST", + body: { "table-name": "物品表" } + } + }), + (error: unknown) => { + assert.ok(error instanceof ServiceError); + assert.equal(error.statusCode, 400); + assert.equal(error.message, "GetTableDataWithOffset body.columns must be a non-empty array"); + return true; + } + ); +}); diff --git a/test/case/integration/file-output-content.js b/test/case/integration/file-output-content.js new file mode 100644 index 0000000000000000000000000000000000000000..7f95a59358fa9ec974d86bd80a2a45ecd160b685 --- /dev/null +++ b/test/case/integration/file-output-content.js @@ -0,0 +1,3 @@ +export function normalizeFileOutputContent(content) { + return Buffer.from(content).toString("utf8").trim(); +} diff --git a/test/case/integration/file-output-content.test.js b/test/case/integration/file-output-content.test.js new file mode 100644 index 0000000000000000000000000000000000000000..1354a9af441c18b31759c2bf5677654c838df3f9 --- /dev/null +++ b/test/case/integration/file-output-content.test.js @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeFileOutputContent } from "./file-output-content.js"; + +test("normalizes file output before strict content comparison", () => { + for (const content of [ + "ping", + "ping\n", + "ping\r\n", + " ping \n" + ]) { + assert.equal(normalizeFileOutputContent(Buffer.from(content, "utf8")), "ping"); + } +}); diff --git a/test/case/integration/playwright-flow.js b/test/case/integration/playwright-flow.js index 2ff6c54cccffa1e0cba08c1a0130dd33e2735a3c..724ca26b6b1ba00f25c77748a1e3852320ce541f 100644 --- a/test/case/integration/playwright-flow.js +++ b/test/case/integration/playwright-flow.js @@ -7,6 +7,8 @@ import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; +import { normalizeFileOutputContent } from "./file-output-content.js"; + const localRequire = createRequire(import.meta.url); const managementRequire = createRequire(new URL("../../../packages/daedalus-agent-management-web/package.json", import.meta.url)); @@ -1977,7 +1979,11 @@ async function verifyAgentFileOutputObject(targets, uri, fileName, envJsonPath) assertFileOutputKeyMatches(parsed.key, fileName); const content = await readMinioObjectFromContainer(container, parsed.bucket, parsed.key); - assert.deepEqual(content, Buffer.from("ping", "utf8"), `MinIO object content should be exactly ping: ${parsed.key}`); + assert.equal( + normalizeFileOutputContent(content), + "ping", + `MinIO object content should equal ping after trimming: ${parsed.key}` + ); return { container, diff --git a/test/case/upgrade/openclaw-upgrade-contract.test.js b/test/case/upgrade/openclaw-upgrade-contract.test.js index 0cb2096e8e4051aab364afa6196980ff3adc1556..dc644d081bb25ab72b6fbaddc65dcbaaff776402 100644 --- a/test/case/upgrade/openclaw-upgrade-contract.test.js +++ b/test/case/upgrade/openclaw-upgrade-contract.test.js @@ -20,6 +20,33 @@ test("Docker image pins the supported OpenClaw and Node versions", async () => { assert.match(dockerfile, /^FROM npm-builder-base AS web-installer$/m); }); +test("default workspace skips idle heartbeat model calls", async () => { + const dockerfile = await readRepoFile("docker/Dockerfile"); + const config = JSON.parse(await readRepoFile("docker/assets/config.json")); + const startup = await readRepoFile("docker/assets/scripts/startup.sh"); + const kernelStartup = await readRepoFile("docker/assets/scripts/kernel-startup.sh"); + const heartbeat = await readRepoFile( + "docker/assets/workspace-templates/default/HEARTBEAT.md" + ); + + assert.equal( + heartbeat, + [ + "# Keep this file empty (or with only comments) to skip heartbeat API calls.", + "# Add tasks below when you want the agent to check something periodically.", + "" + ].join("\n") + ); + assert.match(dockerfile, /for doc in AGENTS\.md USER\.md SOUL\.md HEARTBEAT\.md;/); + assert.deepEqual(config.agents.defaults.heartbeat, { + isolatedSession: true, + lightContext: true + }); + assert.match(startup, /isolatedSession: true,\n lightContext: true/); + assert.match(kernelStartup, /\.agents\.defaults\.heartbeat\.isolatedSession = true/); + assert.match(kernelStartup, /\.agents\.defaults\.heartbeat\.lightContext = true/); +}); + test("Docker npm installs serialize lifecycle scripts and retry transient failures", async () => { const dockerfile = await readRepoFile("docker/Dockerfile"); const retryScript = await readRepoFile("docker/internal/kernel/build-scripts/npm-ci-with-retry.sh"); diff --git a/test/package.json b/test/package.json index 63d4175241d71fd849bb099a45976e9f3996bab0..736d7fc49f7610bbb943b69d041192bd54488451 100644 --- a/test/package.json +++ b/test/package.json @@ -5,6 +5,7 @@ "scripts": { "test:brand": "node --test ./case/branding/daedalus-brand-contract.test.js", "test:upgrade-contract": "node --test ./case/upgrade/openclaw-upgrade-contract.test.js", + "test:integration:contract": "node --test ./case/integration/file-output-content.test.js", "case:integration": "node ./case/integration/playwright-flow.js", "case:stress": "node ./case/stress/stress-flow.js", "test:flow": "node ./suite/integration/run-integration-suite.js full",