Skip to content
16 changes: 16 additions & 0 deletions .changeset/bailian-session-runtime-resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@openagentpack/sdk": minor
---

为百炼 Managed Agents 增加会话运行时资源 API(挂载/列出/查详情/卸载),支持向已创建的
Session 动态追加文件:`mountRemoteSessionResource`、`listRemoteSessionResources`、
`getRemoteSessionResource`、`unmountRemoteSessionResource`(bailian adapter 的
`mountSessionResource` / `listSessionResources` / `getSessionResource` / `unmountSessionResource`
可选方法)。中性层沿用全沙箱路径(`/mnt/session/uploads/…`),bailian wire 自动换算为运行时
端点要求的 `/uploads/…` 前缀并在非法根路径上客户端拒绝;资源响应补齐 `SessionResourceInfo.id`
(`sesrsc_…`,卸载凭据)。挂载与卸载不产生会话事件;provider 返回的 `file_id` 是服务端内部副本
id,完整载荷保留在 `attributes` 中。运行时挂载不进入 `getSession` 的 `resources` 回显,对账请
使用列表端点。合同经 2026-11-02 live 实测校准。

上述四个 SDK 入口接受仅含 Provider 配置的 `ManagedApiContext`,无需项目配置或本地 State;
现有 `ProjectRuntimeContext` 调用保持兼容。
7 changes: 7 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export {
getRemoteDeployment,
getRemoteDeploymentRun,
getRemoteEnvironment,
getRemoteSessionResource,
getRemoteSkill,
getRemoteSkillDownloadInfo,
getRemoteSkillVersion,
Expand All @@ -161,12 +162,15 @@ export {
listRemoteDeployments,
listRemoteEnvironments,
listRemoteFiles,
listRemoteSessionResources,
listRemoteSkills,
listRemoteSkillVersions,
listRemoteVaults,
mountRemoteSessionResource,
runRemoteDeployment,
sendRemoteSessionEvents,
setRemoteDeploymentPaused,
unmountRemoteSessionResource,
updateRemoteSession,
} from "./internal/core/managed-api-runtime.ts";
export type { ManagedApiTarget } from "./internal/core/managed-api-runtime.ts";
Expand All @@ -187,6 +191,9 @@ export type {
ManagedAgentProviderCapabilities,
SessionEventInput,
SessionEventSendResult,
SessionResourceListOptions,
SessionResourceMountInput,
SessionResourcePage,
SessionUpdateInput,
SkillDownloadInfo,
SkillListOptions,
Expand Down
49 changes: 48 additions & 1 deletion packages/sdk/src/internal/core/managed-api-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type {
ManagedAgentProviderCapabilities,
SessionEventInput,
SessionEventSendResult,
SessionResourceListOptions,
SessionResourceMountInput,
SessionResourcePage,
SessionUpdateInput,
SkillDownloadInfo,
SkillListOptions,
Expand All @@ -30,9 +33,10 @@ import type {
VaultListOptions,
VaultPage,
} from "../types/managed-api.ts";
import type { ProviderSessionInfo } from "../types/session.ts";
import type { ProviderSessionInfo, SessionResourceInfo } from "../types/session.ts";
import type { ProviderSkillInfo } from "../types/skill-info.ts";
import {
type ManagedApiContext,
requireManagedApiMethod as requiredMethod,
resolveManagedApiAdapter as resolveAdapter,
} from "./managed-api-context.ts";
Expand Down Expand Up @@ -209,6 +213,49 @@ export async function archiveRemoteSession(
return method.call(adapter, id);
}

export async function mountRemoteSessionResource(
ctx: ManagedApiContext,
sessionId: string,
input: SessionResourceMountInput,
options: ManagedApiTarget = {},
): Promise<SessionResourceInfo> {
const adapter = resolveAdapter(ctx, options.provider);
const method = requiredMethod(adapter.mountSessionResource, adapter.name, "session resource mounting");
return method.call(adapter, sessionId, input);
}

export async function listRemoteSessionResources(
ctx: ManagedApiContext,
sessionId: string,
options: ManagedApiTarget & SessionResourceListOptions = {},
): Promise<SessionResourcePage> {
const adapter = resolveAdapter(ctx, options.provider);
const method = requiredMethod(adapter.listSessionResources, adapter.name, "session resource listing");
return method.call(adapter, sessionId, options);
}

export async function getRemoteSessionResource(
ctx: ManagedApiContext,
sessionId: string,
resourceId: string,
options: ManagedApiTarget = {},
): Promise<SessionResourceInfo> {
const adapter = resolveAdapter(ctx, options.provider);
const method = requiredMethod(adapter.getSessionResource, adapter.name, "session resource lookup");
return method.call(adapter, sessionId, resourceId);
}

export async function unmountRemoteSessionResource(
ctx: ManagedApiContext,
sessionId: string,
resourceId: string,
options: ManagedApiTarget = {},
): Promise<void> {
const adapter = resolveAdapter(ctx, options.provider);
const method = requiredMethod(adapter.unmountSessionResource, adapter.name, "session resource unmounting");
return method.call(adapter, sessionId, resourceId);
}

export async function sendRemoteSessionEvents(
ctx: ProjectRuntimeContext,
id: string,
Expand Down
72 changes: 71 additions & 1 deletion packages/sdk/src/internal/providers/bailian/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import type {
FilePage,
SessionEventInput,
SessionEventSendResult,
SessionResourceListOptions,
SessionResourceMountInput,
SessionResourcePage,
SessionUpdateInput,
SkillDownloadInfo,
SkillListOptions,
Expand All @@ -56,7 +59,13 @@ import type {
UpdateMemoryInput,
UpdateMemoryStoreInput,
} from "../../types/memory.ts";
import type { ProviderSessionInfo, SessionBindings, SessionFilter, SessionListResult } from "../../types/session.ts";
import type {
ProviderSessionInfo,
SessionBindings,
SessionFilter,
SessionListResult,
SessionResourceInfo,
} from "../../types/session.ts";
import type {
EventListOptions,
EventStreamOptions,
Expand Down Expand Up @@ -1211,6 +1220,35 @@ export class BailianAdapter implements ProviderAdapter {
await this.client.delete(`/sessions/${id}`);
}

async mountSessionResource(sessionId: string, input: SessionResourceMountInput): Promise<SessionResourceInfo> {
const res = (await this.client.post(`/sessions/${sessionId}/resources`, {
type: input.type,
file_id: input.file_id,
// The runtime endpoint takes the path under the sandbox session root
// ("/uploads/…"), while the SDK's neutral layer — and the create-session
// API — use the full sandbox path ("/mnt/session/uploads/…").
mount_path: toRuntimeResourceMountPath(input.mount_path),
})) as Record<string, unknown>;
return toSessionResourceInfo(res);
}

async listSessionResources(sessionId: string, options?: SessionResourceListOptions): Promise<SessionResourcePage> {
const res = (await this.client.get(withQuery(`/sessions/${sessionId}/resources`, cursorParams(options)))) as Record<
string,
unknown
>;
return cursorPage(res, toSessionResourceInfo);
}

async getSessionResource(sessionId: string, resourceId: string): Promise<SessionResourceInfo> {
const res = (await this.client.get(`/sessions/${sessionId}/resources/${resourceId}`)) as Record<string, unknown>;
return toSessionResourceInfo(res);
}

async unmountSessionResource(sessionId: string, resourceId: string): Promise<void> {
await this.client.delete(`/sessions/${sessionId}/resources/${resourceId}`);
}

async sendSessionMessage(sessionId: string, message: string): Promise<string | undefined> {
const body = mapSendMessage(message);
const res = (await this.client.post(`/sessions/${sessionId}/events`, body)) as Record<string, unknown>;
Expand Down Expand Up @@ -1363,6 +1401,38 @@ export function toSessionInfo(res: Record<string, unknown>): ProviderSessionInfo
return sessionInfo;
}

/** Sandbox session root on bailian; the runtime resources API takes paths relative to it. */
const BAILIAN_SESSION_SANDBOX_ROOT = "/mnt/session";

/**
* Runtime resource mount_path wire convention: absolute under `/uploads/`. Accept the
* full sandbox path used by the neutral layer and create-session API (`/mnt/session/uploads/…`),
* or an already wire-shaped `/uploads/…` path; anything else is rejected client-side.
*/
function toRuntimeResourceMountPath(mountPath: string): string {
const wirePath = mountPath.startsWith(`${BAILIAN_SESSION_SANDBOX_ROOT}/`)
? mountPath.slice(BAILIAN_SESSION_SANDBOX_ROOT.length)
: mountPath;
if (wirePath === "/uploads" || wirePath.startsWith("/uploads/")) return wirePath;
throw new UserError(
`bailian runtime mount_path must resolve to '/uploads/…' (sandbox '${BAILIAN_SESSION_SANDBOX_ROOT}/uploads/…'); received '${mountPath}'.`,
);
}

/** Raw bailian session-resource object → neutral SessionResourceInfo; the full payload rides in `attributes`. */
function toSessionResourceInfo(res: Record<string, unknown>): SessionResourceInfo {
return {
...(typeof res.id === "string" ? { id: res.id } : {}),
type: (res.type as string) ?? "file",
...(typeof res.mount_path === "string" ? { mount_path: res.mount_path } : {}),
...(typeof res.file_id === "string" ? { file_id: res.file_id } : {}),
...(typeof res.memory_store_id === "string" ? { memory_store_id: res.memory_store_id } : {}),
...(res.access === "read_write" || res.access === "read_only" ? { access: res.access } : {}),
...(typeof res.instructions === "string" ? { instructions: res.instructions } : {}),
attributes: res,
};
}

// Raw Bailian `/skills` item → neutral ProviderSkillInfo. Kept provider-local (not the
// shared toRestSkillInfo) because bailian's wire differs: name is `name` (not display_title),
// the custom catalog reports `customer`, and skills carry a real security-scan status.
Expand Down
18 changes: 17 additions & 1 deletion packages/sdk/src/internal/providers/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import type {
FilePage,
SessionEventInput,
SessionEventSendResult,
SessionResourceListOptions,
SessionResourceMountInput,
SessionResourcePage,
SessionUpdateInput,
SkillDownloadInfo,
SkillListOptions,
Expand Down Expand Up @@ -54,7 +57,13 @@ import type {
UpdateMemoryInput,
UpdateMemoryStoreInput,
} from "../types/memory.ts";
import type { ProviderSessionInfo, SessionBindings, SessionFilter, SessionListResult } from "../types/session.ts";
import type {
ProviderSessionInfo,
SessionBindings,
SessionFilter,
SessionListResult,
SessionResourceInfo,
} from "../types/session.ts";
import type {
EventListOptions,
EventStreamOptions,
Expand Down Expand Up @@ -452,6 +461,13 @@ export interface ProviderAdapter {
archiveSession?(id: string): Promise<ProviderSessionInfo>;
deleteSession(id: string): Promise<void>;

/** Mount one resource into a RUNNING session. Produces no session events. */
mountSessionResource?(sessionId: string, input: SessionResourceMountInput): Promise<SessionResourceInfo>;
listSessionResources?(sessionId: string, options?: SessionResourceListOptions): Promise<SessionResourcePage>;
getSessionResource?(sessionId: string, resourceId: string): Promise<SessionResourceInfo>;
/** Unmount one resource and clear the provider's internal file copy. Unrecoverable. */
unmountSessionResource?(sessionId: string, resourceId: string): Promise<void>;

sendSessionMessage(sessionId: string, message: string): Promise<string | undefined>;
sendSessionEvents?(sessionId: string, events: SessionEventInput[]): Promise<SessionEventSendResult>;
streamSessionEvents(sessionId: string, options?: EventStreamOptions): AsyncIterable<ProviderSessionEvent>;
Expand Down
13 changes: 13 additions & 0 deletions packages/sdk/src/internal/types/managed-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CredentialInjectionLocation, CredentialNetworking } from "./config.ts";
import type { CloudAgent, CloudEnvironment, CloudVault } from "./dto.ts";
import type { ProviderFileInfo } from "./file.ts";
import type { SessionResourceInfo } from "./session.ts";
import type { ProviderSkillInfo } from "./skill-info.ts";

export interface CursorPage<T> {
Expand Down Expand Up @@ -87,6 +88,18 @@ export interface SessionUpdateInput {
metadata?: Record<string, string>;
}

/** One resource to mount into a RUNNING session (provider session-resources API). */
export interface SessionResourceMountInput {
type: "file";
file_id: string;
/** Sandbox path inside the session, e.g. `/mnt/session/uploads/report.csv`. */
mount_path: string;
}

export type SessionResourceListOptions = CursorListOptions;

export type SessionResourcePage = CursorPage<SessionResourceInfo>;

/** Raw AgentStudio message/event input. Provider adapters preserve the wire shape. */
export type SessionEventInput = Record<string, unknown>;

Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type SessionResource = SessionGithubRepositoryResource | SessionMemorySto

/** Provider response resource, including server-assigned sandbox mount paths. */
export interface SessionResourceInfo extends Record<string, unknown> {
/** Provider-assigned resource id (`sesrsc_…`); required to unmount a runtime-mounted resource. */
id?: string;
type: string;
mount_path?: string;
memory_store_id?: string;
Expand Down
82 changes: 82 additions & 0 deletions packages/sdk/tests/unit/bailian-managed-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,88 @@ describe("Bailian session API additions", () => {
});
});

describe("Bailian session runtime resources", () => {
const resource = {
id: "sesrsc_1",
type: "file",
file_id: "file_internal_copy",
memory_store_id: null,
access: null,
instructions: null,
name: null,
description: null,
mount_path: "/mnt/session/uploads/report.csv",
created_at: "2026-11-02T18:19:52+08:00",
updated_at: "2026-11-02T18:19:52+08:00",
request_id: "req_1",
};

test("mounts a running-session file, mapping the sandbox path to the /uploads wire prefix", async () => {
const calls = installJsonFetch([resource]);
const mounted = await adapter().mountSessionResource("sesn_1", {
type: "file",
file_id: "file_original",
mount_path: "/mnt/session/uploads/report.csv",
});

expect(calls[0]!.method).toBe("POST");
expect(calls[0]!.url).toBe(`${BASE}/sessions/sesn_1/resources`);
expect(calls[0]!.body).toEqual({
type: "file",
file_id: "file_original",
mount_path: "/uploads/report.csv",
});
expect(mounted.id).toBe("sesrsc_1");
expect(mounted.type).toBe("file");
expect(mounted.file_id).toBe("file_internal_copy");
expect(mounted.mount_path).toBe("/mnt/session/uploads/report.csv");
expect(mounted.attributes.request_id).toBe("req_1");
});

test("mount accepts an already wire-shaped /uploads path and rejects foreign roots", async () => {
const calls = installJsonFetch([resource]);
await adapter().mountSessionResource("sesn_1", {
type: "file",
file_id: "file_original",
mount_path: "/uploads/a/b.txt",
});
expect(calls[0]!.body).toEqual(expect.objectContaining({ mount_path: "/uploads/a/b.txt" }));

await expect(
adapter().mountSessionResource("sesn_1", {
type: "file",
file_id: "file_original",
mount_path: "/mnt/session/other/x.txt",
}),
).rejects.toThrow("/uploads/");
});

test("lists, gets and unmounts session resources", async () => {
const calls = installJsonFetch([
{ data: [resource], next_page: "P2" },
resource,
{ id: "sesrsc_1", type: "session_resource_deleted", request_id: "req_2" },
]);

const page = await adapter().listSessionResources("sesn_1", { limit: 10, page: "P1" });
const single = await adapter().getSessionResource("sesn_1", "sesrsc_1");
await adapter().unmountSessionResource("sesn_1", "sesrsc_1");

const listUrl = new URL(calls[0]!.url);
expect(calls[0]!.url).toBe(`${BASE}/sessions/sesn_1/resources?limit=10&page=P1`);
expect(listUrl.searchParams.get("limit")).toBe("10");
expect(listUrl.searchParams.get("page")).toBe("P1");
expect(page.data[0]?.id).toBe("sesrsc_1");
expect(page.data[0]?.file_id).toBe("file_internal_copy");
expect(page.next_page).toBe("P2");
expect(calls[1]!.url).toBe(`${BASE}/sessions/sesn_1/resources/sesrsc_1`);
expect(single.id).toBe("sesrsc_1");
expect(single.mount_path).toBe("/mnt/session/uploads/report.csv");
expect(calls[2]!.method).toBe("DELETE");
expect(calls[2]!.url).toBe(`${BASE}/sessions/sesn_1/resources/sesrsc_1`);
});
});

test("operation capabilities expose thread reads and keep unimplemented thread mutations unsupported", () => {
const capabilities = getManagedAgentProviderCapabilities("bailian");
expect(capabilities.operations["agent.create"]?.supported).toBe(true);
Expand Down
Loading