diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd3776a209..14e7de29cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Changes - Security/tools: configured tool sections (`tools.exec`, `tools.fs`) no longer implicitly widen restrictive profiles (`messaging`, `minimal`). Users who need those tools under a restricted profile must add explicit `alsoAllow` entries; a startup warning identifies affected configs. Fixes #47487. Thanks @amknight. +- Gateway/SDK: add SDK-facing artifact list/get/download RPCs and App SDK helpers with transcript provenance and download-source guardrails. Refs #74706. Thanks @tmimmanuel. - Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07. - Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc. - Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 9f83737c23a..fdfc4d4d707 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -3208,6 +3208,188 @@ public struct AgentsFilesSetResult: Codable, Sendable { } } +public struct ArtifactSummary: Codable, Sendable { + public let id: String + public let type: String + public let title: String + public let mimetype: String? + public let sizebytes: Int? + public let sessionkey: String? + public let runid: String? + public let taskid: String? + public let messageseq: Int? + public let source: String? + public let download: [String: AnyCodable] + + public init( + id: String, + type: String, + title: String, + mimetype: String?, + sizebytes: Int?, + sessionkey: String?, + runid: String?, + taskid: String?, + messageseq: Int?, + source: String?, + download: [String: AnyCodable]) + { + self.id = id + self.type = type + self.title = title + self.mimetype = mimetype + self.sizebytes = sizebytes + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + self.messageseq = messageseq + self.source = source + self.download = download + } + + private enum CodingKeys: String, CodingKey { + case id + case type + case title + case mimetype = "mimeType" + case sizebytes = "sizeBytes" + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + case messageseq = "messageSeq" + case source + case download + } +} + +public struct ArtifactsListParams: Codable, Sendable { + public let sessionkey: String? + public let runid: String? + public let taskid: String? + + public init( + sessionkey: String?, + runid: String?, + taskid: String?) + { + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + } +} + +public struct ArtifactsListResult: Codable, Sendable { + public let artifacts: [ArtifactSummary] + + public init( + artifacts: [ArtifactSummary]) + { + self.artifacts = artifacts + } + + private enum CodingKeys: String, CodingKey { + case artifacts + } +} + +public struct ArtifactsGetParams: Codable, Sendable { + public let sessionkey: String? + public let runid: String? + public let taskid: String? + public let artifactid: String + + public init( + sessionkey: String?, + runid: String?, + taskid: String?, + artifactid: String) + { + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + self.artifactid = artifactid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + case artifactid = "artifactId" + } +} + +public struct ArtifactsGetResult: Codable, Sendable { + public let artifact: ArtifactSummary + + public init( + artifact: ArtifactSummary) + { + self.artifact = artifact + } + + private enum CodingKeys: String, CodingKey { + case artifact + } +} + +public struct ArtifactsDownloadParams: Codable, Sendable { + public let sessionkey: String? + public let runid: String? + public let taskid: String? + public let artifactid: String + + public init( + sessionkey: String?, + runid: String?, + taskid: String?, + artifactid: String) + { + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + self.artifactid = artifactid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + case artifactid = "artifactId" + } +} + +public struct ArtifactsDownloadResult: Codable, Sendable { + public let artifact: ArtifactSummary + public let encoding: String? + public let data: String? + public let url: String? + + public init( + artifact: ArtifactSummary, + encoding: String?, + data: String?, + url: String?) + { + self.artifact = artifact + self.encoding = encoding + self.data = data + self.url = url + } + + private enum CodingKeys: String, CodingKey { + case artifact + case encoding + case data + case url + } +} + public struct AgentsListParams: Codable, Sendable {} public struct AgentsListResult: Codable, Sendable { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 9f83737c23a..fdfc4d4d707 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3208,6 +3208,188 @@ public struct AgentsFilesSetResult: Codable, Sendable { } } +public struct ArtifactSummary: Codable, Sendable { + public let id: String + public let type: String + public let title: String + public let mimetype: String? + public let sizebytes: Int? + public let sessionkey: String? + public let runid: String? + public let taskid: String? + public let messageseq: Int? + public let source: String? + public let download: [String: AnyCodable] + + public init( + id: String, + type: String, + title: String, + mimetype: String?, + sizebytes: Int?, + sessionkey: String?, + runid: String?, + taskid: String?, + messageseq: Int?, + source: String?, + download: [String: AnyCodable]) + { + self.id = id + self.type = type + self.title = title + self.mimetype = mimetype + self.sizebytes = sizebytes + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + self.messageseq = messageseq + self.source = source + self.download = download + } + + private enum CodingKeys: String, CodingKey { + case id + case type + case title + case mimetype = "mimeType" + case sizebytes = "sizeBytes" + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + case messageseq = "messageSeq" + case source + case download + } +} + +public struct ArtifactsListParams: Codable, Sendable { + public let sessionkey: String? + public let runid: String? + public let taskid: String? + + public init( + sessionkey: String?, + runid: String?, + taskid: String?) + { + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + } +} + +public struct ArtifactsListResult: Codable, Sendable { + public let artifacts: [ArtifactSummary] + + public init( + artifacts: [ArtifactSummary]) + { + self.artifacts = artifacts + } + + private enum CodingKeys: String, CodingKey { + case artifacts + } +} + +public struct ArtifactsGetParams: Codable, Sendable { + public let sessionkey: String? + public let runid: String? + public let taskid: String? + public let artifactid: String + + public init( + sessionkey: String?, + runid: String?, + taskid: String?, + artifactid: String) + { + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + self.artifactid = artifactid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + case artifactid = "artifactId" + } +} + +public struct ArtifactsGetResult: Codable, Sendable { + public let artifact: ArtifactSummary + + public init( + artifact: ArtifactSummary) + { + self.artifact = artifact + } + + private enum CodingKeys: String, CodingKey { + case artifact + } +} + +public struct ArtifactsDownloadParams: Codable, Sendable { + public let sessionkey: String? + public let runid: String? + public let taskid: String? + public let artifactid: String + + public init( + sessionkey: String?, + runid: String?, + taskid: String?, + artifactid: String) + { + self.sessionkey = sessionkey + self.runid = runid + self.taskid = taskid + self.artifactid = artifactid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + case taskid = "taskId" + case artifactid = "artifactId" + } +} + +public struct ArtifactsDownloadResult: Codable, Sendable { + public let artifact: ArtifactSummary + public let encoding: String? + public let data: String? + public let url: String? + + public init( + artifact: ArtifactSummary, + encoding: String?, + data: String?, + url: String?) + { + self.artifact = artifact + self.encoding = encoding + self.data = data + self.url = url + } + + private enum CodingKeys: String, CodingKey { + case artifact + case encoding + case data + case url + } +} + public struct AgentsListParams: Codable, Sendable {} public struct AgentsListResult: Codable, Sendable { diff --git a/docs/concepts/openclaw-sdk.md b/docs/concepts/openclaw-sdk.md index 8e461a62814..499f2ddd639 100644 --- a/docs/concepts/openclaw-sdk.md +++ b/docs/concepts/openclaw-sdk.md @@ -39,6 +39,7 @@ resources. | `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. | | `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. | | `oc.tools` | Partial | Lists tool catalog and effective tools; direct tool invocation is not wired. | +| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. | | `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. | | `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. | | `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. | @@ -47,8 +48,10 @@ The SDK also exports the core types used by those surfaces: `AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`, `OpenClawEventType`, `GatewayEvent`, `OpenClawTransport`, `GatewayRequestOptions`, `SessionCreateParams`, `SessionSendParams`, -`RuntimeSelection`, `EnvironmentSelection`, `WorkspaceSelection`, -`ApprovalMode`, and related result types. +`ArtifactSummary`, `ArtifactQuery`, `ArtifactsListResult`, +`ArtifactsGetResult`, `ArtifactsDownloadResult`, `RuntimeSelection`, +`EnvironmentSelection`, `WorkspaceSelection`, `ApprovalMode`, and related +result types. ## Connect To A Gateway @@ -204,7 +207,7 @@ for await (const event of run.events()) { For app-wide streams, use `oc.events()`. For raw Gateway frames, use `oc.rawEvents()`. -## Models, Tools, And Approvals +## Models, Tools, Artifacts, And Approvals Model helpers map to current Gateway methods: @@ -220,6 +223,21 @@ await oc.tools.list(); await oc.tools.effective({ sessionKey: "main" }); ``` +Artifact helpers expose the Gateway artifact projection for session, run, or +task context. Each call requires one explicit `sessionKey`, `runId`, or +`taskId` scope: + +```typescript +const { artifacts } = await oc.artifacts.list({ sessionKey: "main" }); +const first = artifacts[0]; + +if (first) { + const { artifact } = await oc.artifacts.get(first.id, { sessionKey: "main" }); + const download = await oc.artifacts.download(artifact.id, { sessionKey: "main" }); + console.log(download.encoding, download.url); +} +``` + Approval helpers use the exec approval RPCs: ```typescript @@ -240,10 +258,6 @@ await oc.tasks.cancel("task-id"); await oc.tools.invoke("tool-name", {}); -await oc.artifacts.list(); -await oc.artifacts.get("artifact-id"); -await oc.artifacts.download("artifact-id"); - await oc.environments.list(); await oc.environments.create({}); await oc.environments.status("environment-id"); diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 9a0fa2870b4..2f572f677c2 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -388,6 +388,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - `agents.list` returns configured agent entries, including effective model and runtime metadata. - `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring. - `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent. + - `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side. - `agent.identity.get` returns the effective assistant identity for an agent or session. - `agent.wait` waits for a run to finish and returns the terminal snapshot when available. diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index b04663c4f02..5cb4821f15c 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -4,6 +4,10 @@ import { normalizeGatewayEvent } from "./normalize.js"; import { GatewayClientTransport, isConnectableTransport } from "./transport.js"; import type { AgentRunParams, + ArtifactQuery, + ArtifactsDownloadResult, + ArtifactsGetResult, + ArtifactsListResult, GatewayEvent, GatewayRequestOptions, OpenClawEvent, @@ -185,6 +189,20 @@ function asRecord(value: unknown): Record { return typeof value === "object" && value !== null ? (value as Record) : {}; } +function hasArtifactQueryScope(params: unknown): params is ArtifactQuery { + const record = asRecord(params); + return [record.sessionKey, record.runId, record.taskId].some( + (value) => typeof value === "string" && value.trim().length > 0, + ); +} + +function requireArtifactQueryScope(api: string, params: unknown): ArtifactQuery { + if (!hasArtifactQueryScope(params)) { + throw new Error(`${api} requires one of sessionKey, runId, or taskId`); + } + return params; +} + function readChatProjection(event: OpenClawEvent): ChatProjection | undefined { const raw = event.raw; if (event.type !== "raw" || raw?.event !== "chat") { @@ -758,19 +776,22 @@ export class ArtifactsNamespace extends RpcNamespace { super(client, "artifacts"); } - async list(params?: unknown): Promise { - void params; - return unsupportedGatewayApi("oc.artifacts.list"); + async list(params: ArtifactQuery): Promise { + return await this.call("list", requireArtifactQueryScope("oc.artifacts.list", params)); } - async get(id: string): Promise { - void id; - return unsupportedGatewayApi("oc.artifacts.get"); + async get(id: string, params: ArtifactQuery): Promise { + return await this.call("get", { + ...requireArtifactQueryScope("oc.artifacts.get", params), + artifactId: id, + }); } - async download(id: string): Promise { - void id; - return unsupportedGatewayApi("oc.artifacts.download"); + async download(id: string, params: ArtifactQuery): Promise { + return await this.call("download", { + ...requireArtifactQueryScope("oc.artifacts.download", params), + artifactId: id, + }); } } diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index adb8e1ee203..52c6cb43220 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -263,6 +263,65 @@ describe("OpenClaw SDK", () => { ).rejects.toThrow("timeoutMs must be a finite non-negative number"); }); + it("calls artifact Gateway RPCs", async () => { + const transport = new FakeTransport({ + "artifacts.list": { artifacts: [{ id: "artifact_123", type: "image", title: "demo.png" }] }, + "artifacts.get": { artifact: { id: "artifact_123", type: "image", title: "demo.png" } }, + "artifacts.download": { + artifact: { id: "artifact_123", type: "image", title: "demo.png" }, + encoding: "base64", + data: "aGVsbG8=", + }, + }); + const oc = new OpenClaw({ transport }); + + await expect(oc.artifacts.list({ sessionKey: "agent:main:main" })).resolves.toMatchObject({ + artifacts: [{ id: "artifact_123" }], + }); + await expect( + oc.artifacts.get("artifact_123", { sessionKey: "agent:main:main" }), + ).resolves.toMatchObject({ + artifact: { id: "artifact_123" }, + }); + await expect( + oc.artifacts.download("artifact_123", { sessionKey: "agent:main:main" }), + ).resolves.toMatchObject({ + encoding: "base64", + data: "aGVsbG8=", + }); + + expect(transport.calls).toMatchObject([ + { + method: "artifacts.list", + params: { sessionKey: "agent:main:main" }, + }, + { + method: "artifacts.get", + params: { artifactId: "artifact_123", sessionKey: "agent:main:main" }, + }, + { + method: "artifacts.download", + params: { artifactId: "artifact_123", sessionKey: "agent:main:main" }, + }, + ]); + }); + + it("requires artifact query scope before calling Gateway", async () => { + const transport = new FakeTransport({}); + const oc = new OpenClaw({ transport }); + + await expect(oc.artifacts.list(undefined as never)).rejects.toThrow( + "oc.artifacts.list requires one of sessionKey, runId, or taskId", + ); + await expect(oc.artifacts.get("artifact_123", undefined as never)).rejects.toThrow( + "oc.artifacts.get requires one of sessionKey, runId, or taskId", + ); + await expect(oc.artifacts.download("artifact_123", undefined as never)).rejects.toThrow( + "oc.artifacts.download requires one of sessionKey, runId, or taskId", + ); + expect(transport.calls).toEqual([]); + }); + it("throws explicit unsupported errors for SDK namespaces without Gateway RPCs", async () => { const transport = new FakeTransport({}); const oc = new OpenClaw({ transport }); @@ -279,15 +338,6 @@ describe("OpenClaw SDK", () => { await expect(oc.tools.invoke("demo")).rejects.toThrow( "oc.tools.invoke is not supported by the current OpenClaw Gateway yet", ); - await expect(oc.artifacts.list()).rejects.toThrow( - "oc.artifacts.list is not supported by the current OpenClaw Gateway yet", - ); - await expect(oc.artifacts.get("artifact_123")).rejects.toThrow( - "oc.artifacts.get is not supported by the current OpenClaw Gateway yet", - ); - await expect(oc.artifacts.download("artifact_123")).rejects.toThrow( - "oc.artifacts.download is not supported by the current OpenClaw Gateway yet", - ); await expect(oc.environments.list()).rejects.toThrow( "oc.environments.list is not supported by the current OpenClaw Gateway yet", ); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index fb1c0799697..e9757f35c2b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -20,7 +20,11 @@ export { GatewayClientTransport, isConnectableTransport } from "./transport.js"; export type { AgentRunParams, ApprovalMode, + ArtifactQuery, ArtifactSummary, + ArtifactsDownloadResult, + ArtifactsGetResult, + ArtifactsListResult, ConnectableOpenClawTransport, EnvironmentSelection, GatewayEvent, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 835d289d9e9..a93d989648f 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -62,7 +62,9 @@ export type SDKMessage = { export type ArtifactSummary = { id: string; runId?: string; + taskId?: string; sessionId?: string; + sessionKey?: string; type: | "file" | "patch" @@ -77,10 +79,35 @@ export type ArtifactSummary = { title?: string; mimeType?: string; sizeBytes?: number; + messageSeq?: number; + source?: string; + download?: { + mode: "bytes" | "url" | "unsupported" | (string & {}); + }; createdAt?: string; expiresAt?: string; }; +export type ArtifactQuery = + | { sessionKey: string; runId?: string; taskId?: string } + | { runId: string; sessionKey?: string; taskId?: string } + | { taskId: string; sessionKey?: string; runId?: string }; + +export type ArtifactsListResult = { + artifacts: ArtifactSummary[]; +}; + +export type ArtifactsGetResult = { + artifact: ArtifactSummary; +}; + +export type ArtifactsDownloadResult = { + artifact: ArtifactSummary; + encoding?: "base64"; + data?: string; + url?: string; +}; + export type SDKError = { code?: string; message: string; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 614c138c5ce..b78cbcac3d3 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -29127,6 +29127,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { tags: ["advanced", "url-secret"], }, }, - version: "2026.4.27", + version: "2026.4.30", generatedAt: "2026-03-22T21:17:33.302Z", }; diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index a2014be1f4a..42ecf2269d6 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -121,6 +121,9 @@ const METHOD_SCOPE_GROUPS: Record = { "talk.config", "agents.files.list", "agents.files.get", + "artifacts.list", + "artifacts.get", + "artifacts.download", ], [WRITE_SCOPE]: [ "message.action", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 1d27dbaf3b6..255ce8358b1 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -38,6 +38,17 @@ import { AgentsFilesSetParamsSchema, type AgentsFilesSetResult, AgentsFilesSetResultSchema, + type ArtifactsDownloadParams, + ArtifactsDownloadParamsSchema, + type ArtifactsDownloadResult, + type ArtifactsGetParams, + ArtifactsGetParamsSchema, + type ArtifactsGetResult, + type ArtifactsListParams, + ArtifactsListParamsSchema, + type ArtifactsListResult, + type ArtifactSummary, + ArtifactSummarySchema, type AgentsListParams, AgentsListParamsSchema, type AgentsListResult, @@ -367,6 +378,12 @@ export const validateAgentsFilesGetParams = ajv.compile( export const validateAgentsFilesSetParams = ajv.compile( AgentsFilesSetParamsSchema, ); +export const validateArtifactsListParams = + ajv.compile(ArtifactsListParamsSchema); +export const validateArtifactsGetParams = ajv.compile(ArtifactsGetParamsSchema); +export const validateArtifactsDownloadParams = ajv.compile( + ArtifactsDownloadParamsSchema, +); export const validateNodePairRequestParams = ajv.compile( NodePairRequestParamsSchema, ); @@ -684,6 +701,10 @@ export { SessionsDeleteParamsSchema, SessionsCompactParamsSchema, SessionsUsageParamsSchema, + ArtifactSummarySchema, + ArtifactsListParamsSchema, + ArtifactsGetParamsSchema, + ArtifactsDownloadParamsSchema, ConfigGetParamsSchema, ConfigSetParamsSchema, ConfigApplyParamsSchema, @@ -845,6 +866,13 @@ export type { AgentsFilesGetResult, AgentsFilesSetParams, AgentsFilesSetResult, + ArtifactSummary, + ArtifactsListParams, + ArtifactsListResult, + ArtifactsGetParams, + ArtifactsGetResult, + ArtifactsDownloadParams, + ArtifactsDownloadResult, AgentsListParams, AgentsListResult, CommandsListParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index c93b4bb06e1..69cf93a03ea 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -1,5 +1,6 @@ export * from "./schema/agent.js"; export * from "./schema/agents-models-skills.js"; +export * from "./schema/artifacts.js"; export * from "./schema/channels.js"; export * from "./schema/commands.js"; export * from "./schema/config.js"; diff --git a/src/gateway/protocol/schema/artifacts.ts b/src/gateway/protocol/schema/artifacts.ts new file mode 100644 index 00000000000..7606e0f9efd --- /dev/null +++ b/src/gateway/protocol/schema/artifacts.ts @@ -0,0 +1,72 @@ +import { Type } from "typebox"; +import { NonEmptyString } from "./primitives.js"; + +const ArtifactQueryParamsProperties = { + sessionKey: Type.Optional(NonEmptyString), + runId: Type.Optional(NonEmptyString), + taskId: Type.Optional(NonEmptyString), +}; + +export const ArtifactQueryParamsSchema = Type.Object(ArtifactQueryParamsProperties, { + additionalProperties: false, +}); + +export const ArtifactGetParamsSchema = Type.Object( + { + ...ArtifactQueryParamsProperties, + artifactId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const ArtifactSummarySchema = Type.Object( + { + id: NonEmptyString, + type: NonEmptyString, + title: NonEmptyString, + mimeType: Type.Optional(NonEmptyString), + sizeBytes: Type.Optional(Type.Integer({ minimum: 0 })), + sessionKey: Type.Optional(NonEmptyString), + runId: Type.Optional(NonEmptyString), + taskId: Type.Optional(NonEmptyString), + messageSeq: Type.Optional(Type.Integer({ minimum: 1 })), + source: Type.Optional(NonEmptyString), + download: Type.Object( + { + mode: Type.Union([Type.Literal("bytes"), Type.Literal("url"), Type.Literal("unsupported")]), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, +); + +export const ArtifactsListParamsSchema = ArtifactQueryParamsSchema; + +export const ArtifactsListResultSchema = Type.Object( + { + artifacts: Type.Array(ArtifactSummarySchema), + }, + { additionalProperties: false }, +); + +export const ArtifactsGetParamsSchema = ArtifactGetParamsSchema; + +export const ArtifactsGetResultSchema = Type.Object( + { + artifact: ArtifactSummarySchema, + }, + { additionalProperties: false }, +); + +export const ArtifactsDownloadParamsSchema = ArtifactGetParamsSchema; + +export const ArtifactsDownloadResultSchema = Type.Object( + { + artifact: ArtifactSummarySchema, + encoding: Type.Optional(Type.Literal("base64")), + data: Type.Optional(Type.String()), + url: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 1f3b90a6f70..99232b0c0fd 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -49,6 +49,15 @@ import { ToolsEffectiveParamsSchema, ToolsEffectiveResultSchema, } from "./agents-models-skills.js"; +import { + ArtifactSummarySchema, + ArtifactsDownloadParamsSchema, + ArtifactsDownloadResultSchema, + ArtifactsGetParamsSchema, + ArtifactsGetResultSchema, + ArtifactsListParamsSchema, + ArtifactsListResultSchema, +} from "./artifacts.js"; import { ChannelsStartParamsSchema, ChannelsLogoutParamsSchema, @@ -333,6 +342,13 @@ export const ProtocolSchemas = { AgentsFilesGetResult: AgentsFilesGetResultSchema, AgentsFilesSetParams: AgentsFilesSetParamsSchema, AgentsFilesSetResult: AgentsFilesSetResultSchema, + ArtifactSummary: ArtifactSummarySchema, + ArtifactsListParams: ArtifactsListParamsSchema, + ArtifactsListResult: ArtifactsListResultSchema, + ArtifactsGetParams: ArtifactsGetParamsSchema, + ArtifactsGetResult: ArtifactsGetResultSchema, + ArtifactsDownloadParams: ArtifactsDownloadParamsSchema, + ArtifactsDownloadResult: ArtifactsDownloadResultSchema, AgentsListParams: AgentsListParamsSchema, AgentsListResult: AgentsListResultSchema, ModelChoice: ModelChoiceSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index a956f9d984f..da328c3ebdf 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -116,6 +116,13 @@ export type AgentsFilesGetParams = SchemaType<"AgentsFilesGetParams">; export type AgentsFilesGetResult = SchemaType<"AgentsFilesGetResult">; export type AgentsFilesSetParams = SchemaType<"AgentsFilesSetParams">; export type AgentsFilesSetResult = SchemaType<"AgentsFilesSetResult">; +export type ArtifactSummary = SchemaType<"ArtifactSummary">; +export type ArtifactsListParams = SchemaType<"ArtifactsListParams">; +export type ArtifactsListResult = SchemaType<"ArtifactsListResult">; +export type ArtifactsGetParams = SchemaType<"ArtifactsGetParams">; +export type ArtifactsGetResult = SchemaType<"ArtifactsGetResult">; +export type ArtifactsDownloadParams = SchemaType<"ArtifactsDownloadParams">; +export type ArtifactsDownloadResult = SchemaType<"ArtifactsDownloadResult">; export type AgentsListParams = SchemaType<"AgentsListParams">; export type AgentsListResult = SchemaType<"AgentsListResult">; export type ModelChoice = SchemaType<"ModelChoice">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 0968ab58a7e..a49a380acf6 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -71,6 +71,9 @@ const BASE_METHODS = [ "agents.files.list", "agents.files.get", "agents.files.set", + "artifacts.list", + "artifacts.get", + "artifacts.download", "skills.status", "skills.search", "skills.detail", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 84c90f4a4d6..34f8aa51c8f 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -6,6 +6,7 @@ import { ErrorCodes, errorShape } from "./protocol/index.js"; import { isRoleAuthorizedForMethod, parseGatewayRole } from "./role-policy.js"; import { agentHandlers } from "./server-methods/agent.js"; import { agentsHandlers } from "./server-methods/agents.js"; +import { artifactsHandlers } from "./server-methods/artifacts.js"; import { channelsHandlers } from "./server-methods/channels.js"; import { chatHandlers } from "./server-methods/chat.js"; import { commandsHandlers } from "./server-methods/commands.js"; @@ -107,6 +108,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...usageHandlers, ...agentHandlers, ...agentsHandlers, + ...artifactsHandlers, }; export async function handleGatewayRequest( diff --git a/src/gateway/server-methods/artifacts.test.ts b/src/gateway/server-methods/artifacts.test.ts new file mode 100644 index 00000000000..e56a13e10af --- /dev/null +++ b/src/gateway/server-methods/artifacts.test.ts @@ -0,0 +1,397 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { artifactsHandlers, collectArtifactsFromMessages } from "./artifacts.js"; + +const hoisted = vi.hoisted(() => ({ + getTaskSessionLookupByIdForStatus: vi.fn(), + loadSessionEntry: vi.fn(), + readSessionMessages: vi.fn(), + resolveSessionKeyForRun: vi.fn(), +})); + +vi.mock("../../tasks/task-status-access.js", () => ({ + getTaskSessionLookupByIdForStatus: hoisted.getTaskSessionLookupByIdForStatus, +})); + +vi.mock("../session-utils.js", async () => { + const actual = await vi.importActual("../session-utils.js"); + return { + ...actual, + loadSessionEntry: hoisted.loadSessionEntry, + readSessionMessages: hoisted.readSessionMessages, + }; +}); + +vi.mock("../server-session-key.js", async () => { + const actual = await vi.importActual( + "../server-session-key.js", + ); + return { + ...actual, + resolveSessionKeyForRun: hoisted.resolveSessionKeyForRun, + }; +}); + +function createResponder() { + const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; + return { + calls, + respond: (ok: boolean, payload?: unknown, error?: unknown) => { + calls.push({ ok, payload, error }); + }, + }; +} + +describe("artifacts RPC handlers", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue(undefined); + hoisted.loadSessionEntry.mockReturnValue({ + storePath: "/tmp/sessions.json", + entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" }, + }); + hoisted.readSessionMessages.mockReturnValue([ + { + role: "assistant", + content: [ + { type: "text", text: "see attached" }, + { + type: "image", + data: "aGVsbG8=", + mimeType: "image/png", + alt: "result.png", + }, + ], + __openclaw: { seq: 2 }, + }, + ]); + }); + + it("lists stable transcript artifact summaries by sessionKey", async () => { + const { calls, respond } = createResponder(); + + await artifactsHandlers["artifacts.list"]?.({ + req: { type: "req", id: "1", method: "artifacts.list", params: {} }, + params: { sessionKey: "agent:main:main" }, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.ok).toBe(true); + const payload = calls[0]?.payload as { artifacts?: Array> }; + expect(payload.artifacts).toHaveLength(1); + expect(payload.artifacts?.[0]).toMatchObject({ + type: "image", + title: "result.png", + mimeType: "image/png", + sizeBytes: 5, + sessionKey: "agent:main:main", + messageSeq: 2, + source: "session-transcript", + download: { mode: "bytes" }, + }); + expect(payload.artifacts?.[0]?.id).toMatch(/^artifact_/); + expect(payload.artifacts?.[0]).not.toHaveProperty("data"); + }); + + it("gets and downloads an inline artifact", async () => { + const listed = collectArtifactsFromMessages({ + sessionKey: "agent:main:main", + messages: hoisted.readSessionMessages(), + }); + const artifactId = listed[0]?.id; + expect(artifactId).toBeTruthy(); + + const get = createResponder(); + await artifactsHandlers["artifacts.get"]?.({ + req: { type: "req", id: "2", method: "artifacts.get", params: {} }, + params: { sessionKey: "agent:main:main", artifactId }, + client: null, + isWebchatConnect: () => false, + respond: get.respond, + context: {} as never, + }); + expect(get.calls[0]?.ok).toBe(true); + expect(get.calls[0]?.payload).toMatchObject({ + artifact: { id: artifactId, download: { mode: "bytes" } }, + }); + + const download = createResponder(); + await artifactsHandlers["artifacts.download"]?.({ + req: { type: "req", id: "3", method: "artifacts.download", params: {} }, + params: { sessionKey: "agent:main:main", artifactId }, + client: null, + isWebchatConnect: () => false, + respond: download.respond, + context: {} as never, + }); + expect(download.calls[0]?.ok).toBe(true); + expect(download.calls[0]?.payload).toMatchObject({ + encoding: "base64", + data: "aGVsbG8=", + artifact: { id: artifactId }, + }); + }); + + it("resolves runId queries through the gateway run-to-session lookup", async () => { + hoisted.resolveSessionKeyForRun.mockReturnValue("agent:main:main"); + hoisted.readSessionMessages.mockReturnValue([ + { + role: "assistant", + content: [{ type: "image", data: "aGVsbG8=", alt: "run-result.png" }], + __openclaw: { seq: 2, runId: "run-1" }, + }, + ]); + const { calls, respond } = createResponder(); + + await artifactsHandlers["artifacts.list"]?.({ + req: { type: "req", id: "4", method: "artifacts.list", params: {} }, + params: { runId: "run-1" }, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(calls[0]?.ok).toBe(true); + expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1"); + const payload = calls[0]?.payload as { artifacts?: Array> }; + expect(payload.artifacts?.[0]).toMatchObject({ runId: "run-1" }); + }); + + it("resolves taskId queries through task status access and filters artifacts by messageTaskId", async () => { + hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({ + requesterSessionKey: "agent:main:main", + runId: "run-for-task-1", + }); + hoisted.readSessionMessages.mockReturnValue([ + { + role: "assistant", + content: [{ type: "image", data: "dGFyZ2V0", alt: "task-result.png" }], + __openclaw: { seq: 2, messageTaskId: "task-1" }, + }, + { + role: "assistant", + content: [{ type: "image", data: "b3RoZXI=", alt: "other-task.png" }], + __openclaw: { seq: 3, messageTaskId: "task-2" }, + }, + { + role: "assistant", + content: [{ type: "image", data: "dW50YWdnZWQ=", alt: "untagged.png" }], + __openclaw: { seq: 4 }, + }, + ]); + + const list = createResponder(); + await artifactsHandlers["artifacts.list"]?.({ + req: { type: "req", id: "task-list", method: "artifacts.list", params: {} }, + params: { taskId: "task-1" }, + client: null, + isWebchatConnect: () => false, + respond: list.respond, + context: {} as never, + }); + + expect(list.calls[0]?.ok).toBe(true); + expect(hoisted.getTaskSessionLookupByIdForStatus).toHaveBeenCalledWith("task-1"); + expect(hoisted.resolveSessionKeyForRun).not.toHaveBeenCalled(); + expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:main:main"); + const listPayload = list.calls[0]?.payload as { artifacts?: Array> }; + expect(listPayload.artifacts).toHaveLength(1); + expect(listPayload.artifacts?.[0]).toMatchObject({ + taskId: "task-1", + title: "task-result.png", + }); + + const artifactId = listPayload.artifacts?.[0]?.id as string | undefined; + expect(artifactId).toBeTruthy(); + + const get = createResponder(); + await artifactsHandlers["artifacts.get"]?.({ + req: { type: "req", id: "task-get", method: "artifacts.get", params: {} }, + params: { taskId: "task-1", artifactId }, + client: null, + isWebchatConnect: () => false, + respond: get.respond, + context: {} as never, + }); + expect(get.calls[0]?.ok).toBe(true); + expect(get.calls[0]?.payload).toMatchObject({ + artifact: { id: artifactId, taskId: "task-1", title: "task-result.png" }, + }); + + const download = createResponder(); + await artifactsHandlers["artifacts.download"]?.({ + req: { type: "req", id: "task-download", method: "artifacts.download", params: {} }, + params: { taskId: "task-1", artifactId }, + client: null, + isWebchatConnect: () => false, + respond: download.respond, + context: {} as never, + }); + expect(download.calls[0]?.ok).toBe(true); + expect(download.calls[0]?.payload).toMatchObject({ + encoding: "base64", + data: "dGFyZ2V0", + artifact: { id: artifactId, taskId: "task-1", title: "task-result.png" }, + }); + }); + + it("does not return untagged session artifacts for scoped runId queries", async () => { + hoisted.resolveSessionKeyForRun.mockReturnValue("agent:main:main"); + const { calls, respond } = createResponder(); + + await artifactsHandlers["artifacts.list"]?.({ + req: { type: "req", id: "run-scope", method: "artifacts.list", params: {} }, + params: { runId: "run-1" }, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(calls[0]?.ok).toBe(true); + expect(calls[0]?.payload).toEqual({ artifacts: [] }); + }); + + it("discovers transcript image_url data blocks", async () => { + hoisted.readSessionMessages.mockReturnValue([ + { + role: "user", + content: [ + { + type: "input_image", + image_url: "data:image/png;base64,aGVsbG8=", + alt: "uploaded.png", + }, + ], + __openclaw: { seq: 3 }, + }, + ]); + const { calls, respond } = createResponder(); + + await artifactsHandlers["artifacts.list"]?.({ + req: { type: "req", id: "image-url", method: "artifacts.list", params: {} }, + params: { sessionKey: "agent:main:main" }, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + + expect(calls[0]?.ok).toBe(true); + const payload = calls[0]?.payload as { artifacts?: Array> }; + expect(payload.artifacts).toHaveLength(1); + expect(payload.artifacts?.[0]).toMatchObject({ + type: "image", + title: "uploaded.png", + mimeType: "image/png", + sizeBytes: 5, + download: { mode: "bytes" }, + }); + }); + + it("treats transcript non-base64 data URLs as unsupported downloads", () => { + const artifacts = collectArtifactsFromMessages({ + sessionKey: "agent:main:main", + messages: [ + { + role: "user", + content: [ + { + type: "input_image", + image_url: "data:text/plain,hello", + alt: "uploaded.txt", + }, + ], + __openclaw: { seq: 4 }, + }, + ], + }); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]).toMatchObject({ + type: "image", + title: "uploaded.txt", + download: { mode: "unsupported" }, + }); + expect(artifacts[0]?.download).not.toHaveProperty("encoding", "base64"); + }); + + it("treats non-base64 data URLs in the content field as unsupported downloads", () => { + const artifacts = collectArtifactsFromMessages({ + sessionKey: "agent:main:main", + messages: [ + { + role: "assistant", + content: [ + { + type: "file", + content: "data:text/plain,hello", + title: "plain.txt", + }, + ], + __openclaw: { seq: 5 }, + }, + ], + }); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]).toMatchObject({ + title: "plain.txt", + download: { mode: "unsupported" }, + }); + expect(artifacts[0]).not.toHaveProperty("data"); + }); + + it("treats unsafe artifact URLs as unsupported downloads", async () => { + const artifacts = collectArtifactsFromMessages({ + sessionKey: "agent:main:main", + messages: [ + { + role: "assistant", + content: [{ type: "file", title: "secret.txt", url: "file:///etc/passwd" }], + __openclaw: { seq: 4 }, + }, + ], + }); + + expect(artifacts[0]).toMatchObject({ + title: "secret.txt", + download: { mode: "unsupported" }, + }); + expect(artifacts[0]).not.toHaveProperty("url"); + }); + + it("returns typed errors for missing query scope and missing artifacts", async () => { + const missingScope = createResponder(); + await artifactsHandlers["artifacts.list"]?.({ + req: { type: "req", id: "5", method: "artifacts.list", params: {} }, + params: {}, + client: null, + isWebchatConnect: () => false, + respond: missingScope.respond, + context: {} as never, + }); + expect(missingScope.calls[0]?.ok).toBe(false); + expect(missingScope.calls[0]?.error).toMatchObject({ + details: { type: "artifact_query_unsupported" }, + }); + + const notFound = createResponder(); + await artifactsHandlers["artifacts.get"]?.({ + req: { type: "req", id: "6", method: "artifacts.get", params: {} }, + params: { sessionKey: "agent:main:main", artifactId: "artifact_missing" }, + client: null, + isWebchatConnect: () => false, + respond: notFound.respond, + context: {} as never, + }); + expect(notFound.calls[0]?.ok).toBe(false); + expect(notFound.calls[0]?.error).toMatchObject({ + details: { type: "artifact_not_found", artifactId: "artifact_missing" }, + }); + }); +}); diff --git a/src/gateway/server-methods/artifacts.ts b/src/gateway/server-methods/artifacts.ts new file mode 100644 index 00000000000..e48a1c806c5 --- /dev/null +++ b/src/gateway/server-methods/artifacts.ts @@ -0,0 +1,420 @@ +import { createHash } from "node:crypto"; +import { getTaskSessionLookupByIdForStatus } from "../../tasks/task-status-access.js"; +import { + ErrorCodes, + errorShape, + type ArtifactSummary, + type ArtifactsGetParams, + validateArtifactsDownloadParams, + validateArtifactsGetParams, + validateArtifactsListParams, +} from "../protocol/index.js"; +import { resolveSessionKeyForRun } from "../server-session-key.js"; +import { loadSessionEntry, readSessionMessages } from "../session-utils.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +type ArtifactDownloadMode = ArtifactSummary["download"]["mode"]; + +type ArtifactRecord = ArtifactSummary & { + data?: string; + url?: string; +}; + +type ArtifactQuery = { + sessionKey?: string; + runId?: string; + taskId?: string; +}; + +function artifactError(type: string, message: string, details?: Record) { + return errorShape(ErrorCodes.INVALID_REQUEST, message, { + details: { + type, + ...details, + }, + }); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeArtifactType(value: string): string { + const normalized = value.trim().toLowerCase(); + if (normalized === "image" || normalized === "input_image" || normalized === "image_url") { + return "image"; + } + if (normalized === "audio" || normalized === "input_audio") { + return "audio"; + } + if (normalized === "file" || normalized === "input_file") { + return "file"; + } + return "file"; +} + +function mimeFromDataUrl(value: string): string | undefined { + const match = /^data:([^;,]+)(?:;[^,]*)?,/i.exec(value.trim()); + return match?.[1]?.toLowerCase(); +} + +function base64FromDataUrl(value: string): string | undefined { + const match = /^data:[^,]*;base64,(.*)$/is.exec(value.trim()); + return match?.[1]?.replace(/\s+/g, ""); +} + +function estimateBase64Size(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + try { + return Buffer.from(value, "base64").byteLength; + } catch { + return undefined; + } +} + +function mediaUrlValue(value: unknown): string | undefined { + if (typeof value === "string") { + return asNonEmptyString(value); + } + const record = asRecord(value); + return asNonEmptyString(record?.url); +} + +function isSafeDownloadUrl(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || /^data:/i.test(trimmed)) { + return false; + } + if (trimmed.startsWith("/")) { + return !trimmed.startsWith("//") && trimmed.startsWith("/api/"); + } + try { + const parsed = new URL(trimmed); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function artifactId(parts: { + sessionKey: string; + messageSeq: number; + contentIndex: number; + title: string; + type: string; +}): string { + const hash = createHash("sha256") + .update( + `${parts.sessionKey}\0${parts.messageSeq}\0${parts.contentIndex}\0${parts.type}\0${parts.title}`, + ) + .digest("base64url") + .slice(0, 18); + return `artifact_${hash}`; +} + +function resolveMessageSeq(message: Record, fallback: number): number { + const meta = asRecord(message.__openclaw); + const seq = meta?.seq; + return typeof seq === "number" && Number.isInteger(seq) && seq > 0 ? seq : fallback; +} + +function resolveMessageRunId(message: Record): string | undefined { + const meta = asRecord(message.__openclaw); + return asNonEmptyString(meta?.runId) ?? asNonEmptyString(message.runId); +} + +function resolveMessageTaskId(message: Record): string | undefined { + const meta = asRecord(message.__openclaw); + return ( + asNonEmptyString(meta?.messageTaskId) ?? + asNonEmptyString(meta?.taskId) ?? + asNonEmptyString(message.messageTaskId) ?? + asNonEmptyString(message.taskId) + ); +} + +function resolveBlockDownload(block: Record): { + mode: ArtifactDownloadMode; + data?: string; + url?: string; + mimeType?: string; + sizeBytes?: number; +} { + const data = asNonEmptyString(block.data); + const content = asNonEmptyString(block.content); + const url = asNonEmptyString(block.url) ?? asNonEmptyString(block.openUrl); + const imageUrl = mediaUrlValue(block.image_url); + const audioUrl = asNonEmptyString(block.audio_url); + const source = asRecord(block.source); + const sourceData = asNonEmptyString(source?.data); + const sourceUrl = asNonEmptyString(source?.url); + const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, content, sourceData].find( + (value) => typeof value === "string" && /^data:/i.test(value), + ); + const base64FromDetectedDataUrl = dataUrl ? base64FromDataUrl(dataUrl) : undefined; + const directBase64 = [data, sourceData, content].find( + (value) => typeof value === "string" && !/^data:/i.test(value), + ); + const base64 = base64FromDetectedDataUrl ?? directBase64; + const remoteUrl = [url, sourceUrl, imageUrl, audioUrl].find( + (value) => typeof value === "string" && isSafeDownloadUrl(value), + ); + const mimeType = + asNonEmptyString(block.mimeType) ?? + asNonEmptyString(block.media_type) ?? + asNonEmptyString(source?.media_type) ?? + asNonEmptyString(source?.mimeType) ?? + (dataUrl ? mimeFromDataUrl(dataUrl) : undefined); + const explicitSize = block.sizeBytes ?? source?.sizeBytes; + const sizeBytes = + typeof explicitSize === "number" && Number.isFinite(explicitSize) && explicitSize >= 0 + ? Math.floor(explicitSize) + : estimateBase64Size(base64); + if (base64) { + return { mode: "bytes", data: base64, mimeType, sizeBytes }; + } + if (remoteUrl) { + return { mode: "url", url: remoteUrl, mimeType, sizeBytes }; + } + return { mode: "unsupported", mimeType, sizeBytes }; +} + +function isArtifactBlock(block: Record): boolean { + const type = asNonEmptyString(block.type)?.toLowerCase(); + if ( + type === "image" || + type === "audio" || + type === "file" || + type === "input_image" || + type === "input_audio" || + type === "input_file" || + type === "image_url" + ) { + return true; + } + return Boolean( + block.url || block.openUrl || block.data || block.source || block.image_url || block.audio_url, + ); +} + +export function collectArtifactsFromMessages(params: { + messages: unknown[]; + sessionKey: string; + runId?: string; + taskId?: string; +}): ArtifactRecord[] { + const artifacts: ArtifactRecord[] = []; + let messageFallbackSeq = 0; + for (const message of params.messages) { + const msg = asRecord(message); + if (!msg) { + continue; + } + messageFallbackSeq += 1; + const messageSeq = resolveMessageSeq(msg, messageFallbackSeq); + const messageRunId = resolveMessageRunId(msg); + const messageTaskId = resolveMessageTaskId(msg); + if (params.runId && messageRunId !== params.runId) { + continue; + } + if (params.taskId && messageTaskId !== params.taskId) { + continue; + } + const content = Array.isArray(msg.content) ? msg.content : []; + for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) { + const block = asRecord(content[contentIndex]); + if (!block || !isArtifactBlock(block)) { + continue; + } + const type = normalizeArtifactType(asNonEmptyString(block.type) ?? "file"); + const title = + asNonEmptyString(block.title) ?? + asNonEmptyString(block.fileName) ?? + asNonEmptyString(block.filename) ?? + asNonEmptyString(block.alt) ?? + `${type} ${artifacts.length + 1}`; + const download = resolveBlockDownload(block); + const summary: ArtifactRecord = { + id: artifactId({ + sessionKey: params.sessionKey, + messageSeq, + contentIndex, + title, + type, + }), + type, + title, + ...(download.mimeType ? { mimeType: download.mimeType } : {}), + ...(download.sizeBytes !== undefined ? { sizeBytes: download.sizeBytes } : {}), + sessionKey: params.sessionKey, + ...(messageRunId ? { runId: messageRunId } : {}), + ...(messageTaskId ? { taskId: messageTaskId } : {}), + messageSeq, + source: "session-transcript", + download: { mode: download.mode }, + ...(download.data ? { data: download.data } : {}), + ...(download.url ? { url: download.url } : {}), + }; + artifacts.push(summary); + } + } + return artifacts; +} + +function resolveQuerySessionKey(query: ArtifactQuery): string | undefined { + if (query.sessionKey) { + return query.sessionKey; + } + if (query.runId) { + return resolveSessionKeyForRun(query.runId); + } + if (query.taskId) { + const task = getTaskSessionLookupByIdForStatus(query.taskId); + const requesterSessionKey = asNonEmptyString(task?.requesterSessionKey); + if (requesterSessionKey) { + return requesterSessionKey; + } + const runId = asNonEmptyString(task?.runId); + return runId ? resolveSessionKeyForRun(runId) : undefined; + } + return undefined; +} + +function loadArtifacts(query: ArtifactQuery): { artifacts: ArtifactRecord[]; sessionKey?: string } { + const sessionKey = resolveQuerySessionKey(query); + if (!sessionKey) { + return { artifacts: [] }; + } + const { storePath, entry } = loadSessionEntry(sessionKey); + const sessionId = entry?.sessionId; + const messages = + sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : []; + return { + sessionKey, + artifacts: collectArtifactsFromMessages({ + messages, + sessionKey, + runId: query.runId, + taskId: query.taskId, + }), + }; +} + +function requireQueryable(params: ArtifactQuery, respond: RespondFn): boolean { + if (params.sessionKey || params.runId || params.taskId) { + return true; + } + respond( + false, + undefined, + artifactError( + "artifact_query_unsupported", + "artifacts require one of sessionKey, runId, or taskId", + ), + ); + return false; +} + +function findArtifact(params: ArtifactsGetParams): { + artifact?: ArtifactRecord; + sessionKey?: string; +} { + const loaded = loadArtifacts(params); + return { + sessionKey: loaded.sessionKey, + artifact: loaded.artifacts.find((artifact) => artifact.id === params.artifactId), + }; +} + +function toSummary(artifact: ArtifactRecord): ArtifactSummary { + const { data: _data, url: _url, ...summary } = artifact; + return summary; +} + +export const artifactsHandlers: GatewayRequestHandlers = { + "artifacts.list": ({ params, respond }) => { + if (!assertValidParams(params, validateArtifactsListParams, "artifacts.list", respond)) { + return; + } + if (!requireQueryable(params, respond)) { + return; + } + const { artifacts, sessionKey } = loadArtifacts(params); + if (!sessionKey && (params.runId || params.taskId)) { + respond( + false, + undefined, + artifactError("artifact_scope_not_found", "no session found for artifact query"), + ); + return; + } + respond(true, { artifacts: artifacts.map(toSummary) }); + }, + "artifacts.get": ({ params, respond }) => { + if (!assertValidParams(params, validateArtifactsGetParams, "artifacts.get", respond)) { + return; + } + if (!requireQueryable(params, respond)) { + return; + } + const { artifact } = findArtifact(params); + if (!artifact) { + respond( + false, + undefined, + artifactError("artifact_not_found", "artifact not found", { + artifactId: params.artifactId, + }), + ); + return; + } + respond(true, { artifact: toSummary(artifact) }); + }, + "artifacts.download": ({ params, respond }) => { + if ( + !assertValidParams(params, validateArtifactsDownloadParams, "artifacts.download", respond) + ) { + return; + } + if (!requireQueryable(params, respond)) { + return; + } + const { artifact } = findArtifact(params); + if (!artifact) { + respond( + false, + undefined, + artifactError("artifact_not_found", "artifact not found", { + artifactId: params.artifactId, + }), + ); + return; + } + if (artifact.download.mode === "unsupported") { + respond( + false, + undefined, + artifactError("artifact_download_unsupported", "artifact download is unsupported", { + artifactId: artifact.id, + }), + ); + return; + } + respond(true, { + artifact: toSummary(artifact), + ...(artifact.download.mode === "bytes" + ? { encoding: "base64" as const, data: artifact.data } + : {}), + ...(artifact.download.mode === "url" ? { url: artifact.url } : {}), + }); + }, +}; diff --git a/src/tasks/task-status-access.ts b/src/tasks/task-status-access.ts index 0e33b3587b9..8cc69f2b705 100644 --- a/src/tasks/task-status-access.ts +++ b/src/tasks/task-status-access.ts @@ -1,6 +1,18 @@ -import { listTasksForAgentId, listTasksForSessionKey } from "./task-registry.js"; +import { getTaskById, listTasksForAgentId, listTasksForSessionKey } from "./task-registry.js"; import type { TaskRecord } from "./task-registry.types.js"; +export function getTaskSessionLookupByIdForStatus( + taskId: string, +): Pick | undefined { + const task = getTaskById(taskId); + return task + ? { + requesterSessionKey: task.requesterSessionKey, + ...(task.runId ? { runId: task.runId } : {}), + } + : undefined; +} + export function listTasksForSessionKeyForStatus(sessionKey: string): TaskRecord[] { return listTasksForSessionKey(sessionKey); }