Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/web/src/features/sessions/SessionRuntimeSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { AgentSession, RuntimeObservation } from "@oac/agents-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, expect, it } from "vitest";

import i18n from "../../i18n";
import { SessionRuntimeSection } from "./SessionRuntimeSection";
import { sessionObservationQuery } from "./session-queries";

const session = { id: "session", status: "idle" } as AgentSession;
const unsupported: RuntimeObservation = {
id: session.id, object: "agent.runtime_observation", session_id: session.id,
environment_id: "environment", mode: "openai_hosted", provider_type: "docker",
instance: { kind: "managed_allocation", allocation_id: "allocation", connection_generation: null },
lifecycle_state: "active", status: "unsupported", reason: "native_metrics_not_supported",
allocation_created_at: null, resolved_at: 30, observed_at: null, started_at: null, cpu: null, memory: null,
};

function render(observation: RuntimeObservation): string {
const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false } } });
client.setQueryData(sessionObservationQuery("project", session.id).queryKey, observation);
const html = renderToStaticMarkup(<QueryClientProvider client={client}>
<SessionRuntimeSection projectId="project" session={session} active={false} revision={0} refreshToken={0} />
</QueryClientProvider>);
client.clear();
return html;
}

afterEach(async () => { await i18n.changeLanguage("en"); });

it.each(["en", "zh-CN"])("renders a declared unsupported reason without samples and retains known translations in %s", async (language) => {
await i18n.changeLanguage(language);
const html = render(unsupported);
expect(html).toContain("native_metrics_not_supported");
expect(html).not.toContain("runtime.reason.native_metrics_not_supported");
const unavailable = render({ ...unsupported, status: "unavailable", reason: "sample_timeout" });
expect(unavailable).toContain(i18n.t("runtime.reason.sample_timeout", { ns: "sessions" }));
expect(unavailable).not.toContain("sample_timeout");
});
2 changes: 1 addition & 1 deletion apps/web/src/features/sessions/SessionRuntimeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function SessionRuntimeSection({
let stateLabel = MISSING;
let stateTone: Tone | undefined;
if (observation?.status === "observed") stateLabel = t(`runtime.lifecycle.${observation.lifecycle_state}`);
else if (observation?.reason) { stateLabel = t(`runtime.reason.${observation.reason}`); stateTone = observation.status === "unavailable" ? "warning" : undefined; }
else if (observation?.reason) { stateLabel = t(`runtime.reason.${observation.reason}`, { defaultValue: observation.reason }); stateTone = observation.status === "unavailable" ? "warning" : undefined; }
const cpu = observation?.status === "observed" ? observation.cpu : null;
const memory = observation?.status === "observed" ? observation.memory : null;
const cpuValue = cpu?.usage_cores != null && cpu.capacity_cores != null
Expand Down
14 changes: 2 additions & 12 deletions contracts/agents-api/core.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,8 @@ definitions:
type: string
x-nullable: true
reason:
enum:
- runtime_mode_not_observable
- allocation_pending
- runtime_not_running
- sample_timeout
- sample_unavailable
type: string
pattern: '^[a-z][a-z0-9_]{0,95}(?![\s\S])'
x-nullable: true
resolved_at:
minimum: 0
Expand Down Expand Up @@ -2891,13 +2886,8 @@ definitions:
type: string
x-nullable: true
reason:
enum:
- runtime_mode_not_observable
- allocation_pending
- runtime_not_running
- sample_timeout
- sample_unavailable
type: string
pattern: '^[a-z][a-z0-9_]{0,95}(?![\s\S])'
x-nullable: true
resolved_at:
minimum: 0
Expand Down
5 changes: 3 additions & 2 deletions contracts/agents-api/runtime-observability-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ This returns one `RuntimeObservation`, without `disk`. It accepts no query param
| `instance` | object | The current compute identity; see [`RuntimeInstance`](#runtimeinstance). |
| `lifecycle_state` | enum or null | Core's own lifecycle view of a managed allocation; null for `none` and `self_hosted`. See below. |
| `status` | enum | `observed`, `unsupported` or `unavailable`. |
| `reason` | enum or null | Why the row has no sample; see [Status and reason](#status-and-reason). |
| `reason` | string or null | Why the row has no sample; see [Status and reason](#status-and-reason). |
| `allocation_created_at` | integer or null | Unix seconds when the managed allocation was created. |
| `resolved_at` | integer | Unix seconds when Core resolved this row. |
| `observed_at` | integer or null | Unix seconds of the provider sample; null without a sample. |
Expand Down Expand Up @@ -141,12 +141,13 @@ Only list rows carry `disk`: null, or `{usage_bytes, limit_bytes}` with the rule
| --- | --- | --- |
| `observed` | null | The provider returned a sample. |
| `unsupported` | `runtime_mode_not_observable` | `none` and `self_hosted` Sessions. |
| `unsupported` | Provider-declared safe code | A managed Runtime whose Provider declares `Observe` unsupported under the [Provider operation contract](../../docs/sandbox-provider.md#explicit-operation-contracts). The allocation ID is present; all sample fields are null. |
| `unavailable` | `allocation_pending` | The managed allocation does not exist yet or is being created. |
| `unavailable` | `runtime_not_running` | The allocation is being cleaned up or is released, or the provider reports the Runtime absent, stopped or suspended. |
| `unavailable` | `sample_timeout` | The provider read exceeded its deadline. |
| `unavailable` | `sample_unavailable` | The provider could not produce a current sample. |

An ownership mismatch, malformed durable identity or invalid provider evidence fails the request instead of becoming an `unavailable` row. The generated `core.openapi.yaml` records each field's type, nullability and enum but cannot express which combinations of status, mode and fields are valid; this table and the field rules above are normative.
An ownership mismatch, malformed durable identity or invalid provider evidence fails the request instead of becoming an `unavailable` row. The generated `core.openapi.yaml` records each field's type, nullability, enum and string pattern but cannot express which combinations of status, mode and fields are valid; this table and the field rules above are normative.

### Errors

Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/v1/runtime_observations.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type RuntimeObservation struct {
Instance RuntimeInstance `json:"instance" binding:"required"`
LifecycleState *string `json:"lifecycle_state" extensions:"x-nullable" binding:"required" enums:"active,sleeping,transitioning,pending,stopped"`
Status string `json:"status" enums:"observed,unsupported,unavailable" binding:"required"`
Reason *string `json:"reason" extensions:"x-nullable" binding:"required" enums:"runtime_mode_not_observable,allocation_pending,runtime_not_running,sample_timeout,sample_unavailable"`
Reason *string `json:"reason" extensions:"x-nullable" binding:"required" pattern:"^[a-z][a-z0-9_]{0,95}(?![\\s\\S])"`
AllocationCreatedAt *int64 `json:"allocation_created_at" extensions:"x-nullable" binding:"required" minimum:"0"`
ResolvedAt int64 `json:"resolved_at" binding:"required" minimum:"0"`
ObservedAt *int64 `json:"observed_at" extensions:"x-nullable" binding:"required" minimum:"0"`
Expand Down
7 changes: 4 additions & 3 deletions contracts/agents-api/zh/runtime-observability-api.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Runtime 遥测 API"
source: contracts/agents-api/runtime-observability-api.md
source_hash: 1c52fa06b312330585b304300b61993d2021105196d58aff7ee0daacafd744b8
source_hash: 0c2d61cebaea7a0b761105582ce7eafe95287efbacc9a7409eceb64a158778d2
---

Core 通过 `/core/v1` 下的只读管理员路由报告托管 Runtime 和沙箱节点所使用的信息:当前 Runtime 观测值、单个 Session 的已存储 Runtime 历史记录,以及沙箱节点的主机观测值和历史记录。读取操作绝不创建、唤醒、续期或更改计算资源,也绝不向历史记录添加样本。[Runtime observability](runtime-observability.md) 定义了 Core 如何采集和保留这些值;[Console API usage](../../../docs/zh/web/console-api-usage.md) 列出了读取这些值的 Web 页面。
Expand Down Expand Up @@ -92,7 +92,7 @@ Authorization: Bearer <Core key>
| `instance` | object | 当前计算资源标识;请参阅 [`RuntimeInstance`](#runtimeinstance)。 |
| `lifecycle_state` | enum 或 null | Core 自身对托管分配的生命周期视图;对于 `none` 和 `self_hosted` 为 null。请参阅下文。 |
| `status` | enum | `observed`、`unsupported` 或 `unavailable`。 |
| `reason` | enum 或 null | 该行没有样本的原因;请参阅 [Status and reason](#status-and-reason)。 |
| `reason` | string 或 null | 该行没有样本的原因;请参阅 [Status and reason](#status-and-reason)。 |
| `allocation_created_at` | integer 或 null | 创建托管分配时的 Unix 秒数。 |
| `resolved_at` | integer | Core 解析此行时的 Unix 秒数。 |
| `observed_at` | integer 或 null | 提供方样本的 Unix 秒数;无样本时为 null。 |
Expand Down Expand Up @@ -143,12 +143,13 @@ Authorization: Bearer <Core key>
| --- | --- | --- |
| `observed` | null | 提供方返回了样本。 |
| `unsupported` | `runtime_mode_not_observable` | `none` 和 `self_hosted` Session。 |
| `unsupported` | 提供方声明的安全代码 | 托管 Runtime 的提供方按[提供方操作契约](../../../docs/zh/sandbox-provider.md#explicit-operation-contracts)声明不支持 `Observe`。分配 ID 必须存在;所有样本字段均为 null。 |
| `unavailable` | `allocation_pending` | 托管分配尚不存在或正在创建。 |
| `unavailable` | `runtime_not_running` | 分配正在清理或已释放,或者提供方报告 Runtime 不存在、已停止或已暂停。 |
| `unavailable` | `sample_timeout` | 提供方读取超过其截止时间。 |
| `unavailable` | `sample_unavailable` | 提供方无法生成当前样本。 |

所有权不匹配、格式错误的持久身份或无效的提供方证据会使请求失败,而不会转换为 `unavailable` 行。生成的 `core.openapi.yaml` 会记录每个字段的类型、可空性和枚举,但无法表达 status、mode 与字段之间哪些组合有效;上表和上述字段规则具有规范效力。
所有权不匹配、格式错误的持久身份或无效的提供方证据会使请求失败,而不会转换为 `unavailable` 行。生成的 `core.openapi.yaml` 会记录每个字段的类型、可空性、枚举和字符串模式,但无法表达 status、mode 与字段之间哪些组合有效;上表和上述字段规则具有规范效力。

### 错误 {#errors}

Expand Down
2 changes: 1 addition & 1 deletion docs/sandbox-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Every provider returns a complete `ProviderOperations()` declaration with one en

`Initial` and `NewCompute` construct compute references without allocating, and `ResumeCompute` thaws only the same resident instance after an aborted pause.

Each declaration entry is `state: supported` with no reason, or `state: unsupported` with an authored reason code. Missing, zero, unknown or unsafe entries fail validation. Adding a method to `SandboxProvider` requires an explicit decision and implementation in every adapter; never supply a base type or generate blanket unsupported implementations.
Each declaration entry is `state: supported` with no reason, or `state: unsupported` with an authored reason code. A safe code contains 1–96 ASCII characters: a lowercase letter first, followed only by lowercase letters, digits or underscores. Missing, zero, unknown or unsafe entries fail validation. Adding a method to `SandboxProvider` requires an explicit decision and implementation in every adapter; never supply a base type or generate blanket unsupported implementations.

An unsupported method returns `providercontract.UnsupportedError` before any native I/O. The error names the exact operation and a safe code, never a native message, resource identity, endpoint or credential. An empty result, a nil error, `Unavailable` or an unknown mutation outcome never stands in for unsupported, and the four required methods can never return it.

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/sandbox-provider.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "添加 Sandbox Provider"
source: docs/sandbox-provider.md
source_hash: 11794813cac6e3f5613935905eeee54fb72c3bdd4d05b4e84f9f4b0702f26aba
source_hash: af35eef1f8c73b311a6166a5aa0352c61f33c71c834803f2e7bab6e51be3d965
---

**Sandbox Provider** 为 Core 管理的 Environment 提供计算资源,以及在其中启动 [Sandbox I/O 服务](#oac-sandbox-io)的有界引导流程;该服务是 Provider 启动的唯一进程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。
Expand Down Expand Up @@ -50,7 +50,7 @@ Docker 等没有原生可续期租约的 backend 仍遵守 Core 的 hosted expir

`Initial` 和 `NewCompute` 构造 compute reference,不分配资源;`ResumeCompute` 在暂停中止后仅解冻同一驻留实例。

每个声明项为不带 reason 的 `state: supported`,或带 authored reason code 的 `state: unsupported`。缺失、零值、未知或不安全项都会验证失败。给 `SandboxProvider` 添加方法时,必须在每个 adapter 中明确决定并实现;不提供 base type,也不生成笼统的不支持实现。
每个声明项为不带 reason 的 `state: supported`,或带 authored reason code 的 `state: unsupported`。安全代码由 1–96 个 ASCII 字符组成:首字符为小写字母,后续仅允许小写字母、数字或下划线。缺失、零值、未知或不安全项都会验证失败。给 `SandboxProvider` 添加方法时,必须在每个 adapter 中明确决定并实现;不提供 base type,也不生成笼统的不支持实现。

不支持的方法在任何原生 I/O 前返回 `providercontract.UnsupportedError`。错误指明精确操作和安全 code,不包含原生消息、资源身份、endpoint 或凭据。空结果、nil error、`Unavailable` 或未知 mutation 结果都不能代替 unsupported,四项必需方法不能返回 unsupported。

Expand Down
60 changes: 60 additions & 0 deletions packages/agents-client/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/// <reference types="node" />
import { readFileSync } from "node:fs";
import observationReasons from "../../../services/core/internal/providercontract/testdata/observation_reasons.json";
import { afterEach, describe, expect, it, vi } from "vitest";

import { AdminClient } from "./admin-client";
import { runtimeUnavailableReasons } from "./types";
import { AgentCoreError, projectAgentSession, CreationStreamRetryError, createIdempotencyKey, isSessionDeletionConflict, OpenAIAgentsClient } from "./client";
import hostedDadf64 from "./fixtures/parsar-dadf64a7/openai-hosted.json";
import eventBatchDadf64 from "./fixtures/parsar-dadf64a7/session-event-batch.json";
Expand Down Expand Up @@ -2744,6 +2748,62 @@ describe("OpenAIAgentsClient", () => {
);
});

it.each(observationReasons.cases)("checks declared reason $reason against schema and both administrator reads", async ({ reason, valid }) => {
const schema = readFileSync(new URL("../../../contracts/agents-api/core.openapi.yaml", import.meta.url), "utf8");
for (const definition of ["v1.RuntimeObservation", "api.AdminRuntimeObservationDetail"]) {
const observation = schema.split(` ${definition}:\n`)[1]?.split(/^ \S/m)[0];
const reasonSchema = observation?.match(/ reason:\n((?: .*\n)+)/)?.[1];
const pattern = reasonSchema?.match(/pattern: '([^']+)'/)?.[1];
expect(pattern).toBe(observationReasons.schema_pattern);
expect(reasonSchema).toContain("type: string");
expect(reasonSchema).toContain("x-nullable: true");
expect(reasonSchema).not.toContain("enum:");
expect(new RegExp(pattern!).test(reason)).toBe(valid);
}

const value = runtimeObservation({ status: "unsupported", reason, observed_at: null, started_at: null, cpu: null, memory: null });
const page = { object: "list", data: [{ project_id: runtimeProjectId, observation: { ...value, disk: null } }], has_more: false, first_id: runtimeSessionId, last_id: runtimeSessionId };
const single = new AdminClient({ fetch: recordingFetch(jsonResponse(value), []) }).retrieveRuntimeObservation(runtimeProjectId, runtimeSessionId);
const list = new AdminClient({ fetch: recordingFetch(jsonResponse(page), []) }).listRuntimeObservations();
if (valid) {
await expect(single).resolves.toEqual(value);
await expect(list).resolves.toEqual(page);
} else {
await expect(single).rejects.toMatchObject({ code: "invalid_runtime_observation" });
await expect(list).rejects.toMatchObject({ code: "invalid_runtime_observation" });
}
});

it("keeps unavailable reasons equal to the shared producer fixture", () => {
expect([...runtimeUnavailableReasons].sort()).toEqual(observationReasons.cases.filter((entry) => entry.unavailable).map((entry) => entry.reason).sort());
});

it.each(observationReasons.cases)("checks unavailable reason $reason", async ({ reason, unavailable }) => {
const value = runtimeObservation({ status: "unavailable", reason, observed_at: null, started_at: null, cpu: null, memory: null });
const client = new AdminClient({ fetch: recordingFetch(jsonResponse(value), []) });
const result = client.retrieveRuntimeObservation(runtimeProjectId, runtimeSessionId);
if (unavailable) await expect(result).resolves.toEqual(value);
else await expect(result).rejects.toMatchObject({ code: "invalid_runtime_observation" });
});

it.each([
["unavailable Provider reason", { status: "unavailable", reason: "native_metrics_not_supported" }],
["unavailable mode reason", { status: "unavailable", reason: "runtime_mode_not_observable" }],
["missing reason", { reason: null }],
["missing allocation", { allocation_created_at: null, instance: { kind: "managed_allocation", allocation_id: null, connection_generation: null } }],
["missing lifecycle", { lifecycle_state: null }],
["observed timestamp", { observed_at: 20 }],
["started timestamp", { started_at: 10 }],
["CPU sample", { cpu: { usage_seconds_total: 1, capacity_cores: null, usage_cores: null, utilization_ratio: null } }],
["memory sample", { memory: { usage_bytes: 1, limit_bytes: null } }],
["none Provider reason", { mode: "none", environment_id: null, provider_type: null, lifecycle_state: null, allocation_created_at: null, instance: { kind: "none", allocation_id: null, connection_generation: null } }],
["self-hosted Provider reason", { mode: "self_hosted", lifecycle_state: null, allocation_created_at: null, instance: { kind: "self_hosted_connection", allocation_id: null, connection_generation: null } }],
])("rejects unsupported combination: %s", async (_, overrides) => {
const value = runtimeObservation({ status: "unsupported", reason: "native_metrics_not_supported", observed_at: null, started_at: null, cpu: null, memory: null, ...overrides });
const client = new AdminClient({ fetch: recordingFetch(jsonResponse(value), []) });
await expect(client.retrieveRuntimeObservation(runtimeProjectId, runtimeSessionId)).rejects.toMatchObject({ code: "invalid_runtime_observation" });
});

it("accepts an unsupported none-mode Runtime observation with explicit nulls", async () => {
const value = runtimeObservation({
environment_id: null,
Expand Down
Loading
Loading