diff --git a/apps/web/src/features/sessions/SessionRuntimeSection.test.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.test.tsx
new file mode 100644
index 000000000..cc6c16b7b
--- /dev/null
+++ b/apps/web/src/features/sessions/SessionRuntimeSection.test.tsx
@@ -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(
+
+ );
+ 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");
+});
diff --git a/apps/web/src/features/sessions/SessionRuntimeSection.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.tsx
index aeadb58ff..398f89e48 100644
--- a/apps/web/src/features/sessions/SessionRuntimeSection.tsx
+++ b/apps/web/src/features/sessions/SessionRuntimeSection.tsx
@@ -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
diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml
index f42491d65..6c235c3a4 100644
--- a/contracts/agents-api/core.openapi.yaml
+++ b/contracts/agents-api/core.openapi.yaml
@@ -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
@@ -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
diff --git a/contracts/agents-api/runtime-observability-api.md b/contracts/agents-api/runtime-observability-api.md
index 79f8ee991..cf185856c 100644
--- a/contracts/agents-api/runtime-observability-api.md
+++ b/contracts/agents-api/runtime-observability-api.md
@@ -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. |
@@ -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
diff --git a/contracts/agents-api/v1/runtime_observations.go b/contracts/agents-api/v1/runtime_observations.go
index 5ceaf3521..0a0f46014 100644
--- a/contracts/agents-api/v1/runtime_observations.go
+++ b/contracts/agents-api/v1/runtime_observations.go
@@ -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"`
diff --git a/contracts/agents-api/zh/runtime-observability-api.md b/contracts/agents-api/zh/runtime-observability-api.md
index 6e7ac50e4..fad30431e 100644
--- a/contracts/agents-api/zh/runtime-observability-api.md
+++ b/contracts/agents-api/zh/runtime-observability-api.md
@@ -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 页面。
@@ -92,7 +92,7 @@ Authorization: Bearer
| `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。 |
@@ -143,12 +143,13 @@ Authorization: Bearer
| --- | --- | --- |
| `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}
diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md
index 848c32c20..d3945db62 100644
--- a/docs/sandbox-provider.md
+++ b/docs/sandbox-provider.md
@@ -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.
diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md
index 5dc9f7145..1aaf60a2b 100644
--- a/docs/zh/sandbox-provider.md
+++ b/docs/zh/sandbox-provider.md
@@ -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)。
@@ -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。
diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts
index e9ee99df3..cad3db5c3 100644
--- a/packages/agents-client/src/client.test.ts
+++ b/packages/agents-client/src/client.test.ts
@@ -1,6 +1,10 @@
+///
+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";
@@ -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,
diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts
index 5a350810a..159330ff0 100644
--- a/packages/agents-client/src/client.ts
+++ b/packages/agents-client/src/client.ts
@@ -1,3 +1,4 @@
+import { runtimeUnavailableReasons } from "./types";
import { coreHarnessKinds } from "./harness-catalog";
import { projectEnvironmentInstallation } from "./installation-projection";
import {
@@ -25,7 +26,7 @@ import {
} from "./generated/public-api";
import {
runtimeCPUObservationFields, runtimeInstanceFields, runtimeMemoryObservationFields, runtimeObservationFields,
- runtimeObservationLifecycleStateValues, runtimeObservationModeValues, runtimeObservationReasonValues, runtimeObservationStatusValues,
+ runtimeObservationLifecycleStateValues, runtimeObservationModeValues, runtimeObservationStatusValues,
} from "./generated/core-api";
import { projectTokenUsage } from "./usage-projection";
import { projectAgentTurn, projectSessionItem, projectItemContent, projectHistoryPage } from "./history-projection";
@@ -884,7 +885,7 @@ export function projectRuntimeObservation(value: unknown, expectedSessionId?: st
)) ||
!isRecord(value.instance) || !exactFields(value.instance, runtimeInstanceFields) ||
!isOneOf(runtimeObservationStatusValues, value.status) ||
- !(value.reason === null || isOneOf(runtimeObservationReasonValues, value.reason)) ||
+ !(value.reason === null || (typeof value.reason === "string" && /^[a-z][a-z0-9_]{0,95}(?![\s\S])/.test(value.reason))) ||
!isNonnegativeInteger(value.resolved_at)
) return invalidRuntimeObservation();
@@ -930,10 +931,10 @@ export function projectRuntimeObservation(value: unknown, expectedSessionId?: st
observedAt !== null || startedAt !== null || value.cpu !== null || value.memory !== null
)) ||
(value.status === "unsupported" && (
- (!isNone && !isSelfHosted) || value.reason !== "runtime_mode_not_observable"
+ value.reason === null || (isManaged ? allocationId === null : value.reason !== "runtime_mode_not_observable")
)) ||
(value.status === "unavailable" && (
- !isManaged || value.reason === null || value.reason === "runtime_mode_not_observable"
+ !isManaged || !isOneOf(runtimeUnavailableReasons, value.reason)
)) ||
(startedAt !== null && observedAt !== null && startedAt > observedAt) ||
(allocationCreatedAt !== null && allocationCreatedAt > value.resolved_at)
diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts
index f6dab94ae..1aec23cce 100644
--- a/packages/agents-client/src/generated/core-api.ts
+++ b/packages/agents-client/src/generated/core-api.ts
@@ -35,7 +35,7 @@ export interface AdminRuntimeObservationDetail {
object: "agent.runtime_observation";
observed_at: number | null;
provider_type: string | null;
- reason: AdminRuntimeObservationDetailReason | null;
+ reason: string | null;
resolved_at: number;
session_id: string;
started_at: number | null;
@@ -46,8 +46,6 @@ export const adminRuntimeObservationDetailLifecycleStateValues = ["active", "sle
export type AdminRuntimeObservationDetailLifecycleState = (typeof adminRuntimeObservationDetailLifecycleStateValues)[number];
export const adminRuntimeObservationDetailModeValues = ["none", "self_hosted", "openai_hosted"] as const;
export type AdminRuntimeObservationDetailMode = (typeof adminRuntimeObservationDetailModeValues)[number];
-export const adminRuntimeObservationDetailReasonValues = ["runtime_mode_not_observable", "allocation_pending", "runtime_not_running", "sample_timeout", "sample_unavailable"] as const;
-export type AdminRuntimeObservationDetailReason = (typeof adminRuntimeObservationDetailReasonValues)[number];
export const adminRuntimeObservationDetailStatusValues = ["observed", "unsupported", "unavailable"] as const;
export type AdminRuntimeObservationDetailStatus = (typeof adminRuntimeObservationDetailStatusValues)[number];
export interface AdminRuntimeObservationList {
@@ -738,7 +736,7 @@ export interface RuntimeObservation {
object: "agent.runtime_observation";
observed_at: number | null;
provider_type: string | null;
- reason: RuntimeObservationReason | null;
+ reason: string | null;
resolved_at: number;
session_id: string;
started_at: number | null;
@@ -749,8 +747,6 @@ export const runtimeObservationLifecycleStateValues = ["active", "sleeping", "tr
export type RuntimeObservationLifecycleState = (typeof runtimeObservationLifecycleStateValues)[number];
export const runtimeObservationModeValues = ["none", "self_hosted", "openai_hosted"] as const;
export type RuntimeObservationMode = (typeof runtimeObservationModeValues)[number];
-export const runtimeObservationReasonValues = ["runtime_mode_not_observable", "allocation_pending", "runtime_not_running", "sample_timeout", "sample_unavailable"] as const;
-export type RuntimeObservationReason = (typeof runtimeObservationReasonValues)[number];
export const runtimeObservationStatusValues = ["observed", "unsupported", "unavailable"] as const;
export type RuntimeObservationStatus = (typeof runtimeObservationStatusValues)[number];
export interface RuntimeRelease {
diff --git a/packages/agents-client/src/protocol-types.test.ts b/packages/agents-client/src/protocol-types.test.ts
index 39cc30384..fd0deaf80 100644
--- a/packages/agents-client/src/protocol-types.test.ts
+++ b/packages/agents-client/src/protocol-types.test.ts
@@ -64,6 +64,7 @@ import type {
describe("Runtime Observation discriminated contract", () => {
it("narrows status, reason, mode, instance, and sample presence together", () => {
type Observed = Extract;
+ type ManagedUnsupported = Extract;
type Unavailable = Extract;
type NoneMode = Extract;
type SelfHosted = Extract;
@@ -73,6 +74,11 @@ describe("Runtime Observation discriminated contract", () => {
expectTypeOf().toEqualTypeOf();
expectTypeOf().toEqualTypeOf();
expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf<"allocation_pending" | "runtime_not_running" | "sample_timeout" | "sample_unavailable">();
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
expectTypeOf().toEqualTypeOf();
expectTypeOf().toEqualTypeOf();
expectTypeOf().toEqualTypeOf<"none">();
diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts
index cd1f55336..6048b7be6 100644
--- a/packages/agents-client/src/types.ts
+++ b/packages/agents-client/src/types.ts
@@ -21,7 +21,7 @@ import type {
} from "./generated/public-api";
import type {
CoreHarness as CoreHarnessResource, ModelConfigurationSupport as ModelConfigurationSupportResource, RuntimeCPUObservation,
- RuntimeMemoryObservation, RuntimeObservationLifecycleState, RuntimeObservationReason,
+ RuntimeMemoryObservation, RuntimeObservationLifecycleState,
} from "./generated/core-api";
// Generated types keep their schema names in ./generated/public-api; these are the client's names for them.
@@ -784,10 +784,11 @@ export interface CreateSessionStreamOptions extends StreamOptions {
// Generated /core/v1 types keep their schema names in ./generated/core-api.
export type {
- HarnessModelConfiguration, RuntimeCPUObservation, RuntimeHistory, RuntimeMemoryObservation, RuntimeObservationReason, SessionExecutionConfiguration,
+ HarnessModelConfiguration, RuntimeCPUObservation, RuntimeHistory, RuntimeMemoryObservation, SessionExecutionConfiguration,
} from "./generated/core-api";
-export type RuntimeUnavailableReason = Exclude;
+export const runtimeUnavailableReasons = ["allocation_pending", "runtime_not_running", "sample_timeout", "sample_unavailable"] as const;
+export type RuntimeUnavailableReason = (typeof runtimeUnavailableReasons)[number];
// The schema's flat observation cannot state each mode's null rules; this union does, and the client validates them.
interface RuntimeObservationBase {
@@ -835,6 +836,12 @@ export interface RuntimeUnavailableObservation extends RuntimeObservationBase {
memory: null;
}
+export interface RuntimeManagedUnsupportedObservation extends Omit {
+ instance: RuntimeObservedObservation["instance"];
+ status: "unsupported";
+ reason: string;
+}
+
export interface RuntimeNoneObservation extends RuntimeObservationBase {
environment_id: null;
mode: "none";
@@ -872,6 +879,7 @@ export interface RuntimeSelfHostedObservation extends RuntimeObservationBase {
export type RuntimeObservation =
| RuntimeObservedObservation
| RuntimeUnavailableObservation
+ | RuntimeManagedUnsupportedObservation
| RuntimeNoneObservation
| RuntimeSelfHostedObservation;
diff --git a/scripts/patch-agents-openapi.py b/scripts/patch-agents-openapi.py
index def3b983d..9239b0976 100644
--- a/scripts/patch-agents-openapi.py
+++ b/scripts/patch-agents-openapi.py
@@ -5,6 +5,7 @@
import sys
import importlib.util
import re
+import json
PROVIDER_TYPE_SCHEMA = """ provider_type:
@@ -61,6 +62,18 @@ def main() -> None:
path = Path(sys.argv[1])
document = harness_enums(path.read_text(encoding="utf-8"))
+ # Swag omits response patterns. The shared Provider fixture checks this
+ # ECMAScript expression against the Go declaration validator.
+ fixture = json.loads((Path(__file__).resolve().parent.parent / "services/core/internal/providercontract/testdata/observation_reasons.json").read_text())
+ for definition in ("v1.RuntimeObservation", "api.AdminRuntimeObservationDetail"):
+ start = document.index(" " + definition + ":\n")
+ end = re.search(r"^ \S", document[start + 1:], re.M).start() + start + 1
+ observation = document[start:end]
+ marker = " reason:\n type: string\n"
+ if observation.count(marker) != 1:
+ raise ValueError("Expected one Runtime observation reason field")
+ observation = observation.replace(marker, marker + " pattern: '" + fixture["schema_pattern"] + "'\n")
+ document = document[:start] + observation + document[end:]
series_start = " v1.RuntimeHistorySeries:\n"
series_end = "\n v1.RuntimeHistoryTime:\n"
if document.count(series_start) != 1 or document.count(series_end) != 1:
diff --git a/services/core/internal/api/runtime_observations_test.go b/services/core/internal/api/runtime_observations_test.go
index 8d68833ee..4a9546419 100644
--- a/services/core/internal/api/runtime_observations_test.go
+++ b/services/core/internal/api/runtime_observations_test.go
@@ -5,11 +5,14 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
+ "reflect"
"strings"
"testing"
"time"
v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs"
"github.com/google/uuid"
)
@@ -203,3 +206,70 @@ func TestAdminRuntimeObservationProjectsReportedUtilizationAndDisk(t *testing.T)
t.Fatalf("unreported disk was not null or reached the project shape: %s %s %v", raw, public, err)
}
}
+
+type declaredObservationSource struct {
+ t *testing.T
+ reason string
+}
+
+func (s declaredObservationSource) ProviderOperations() providercontract.Operations {
+ return providercontract.Operations{"Observe": {State: providercontract.Unsupported, Reason: s.reason}}
+}
+
+func (s declaredObservationSource) Observe(context.Context, runtimeobs.Target) (runtimeobs.Sample, error) {
+ s.t.Fatal("an unsupported operation must not be invoked")
+ return runtimeobs.Sample{}, nil
+}
+
+func (s declaredObservationSource) Resolve(_ context.Context, tenant, session string) (runtimeobs.Target, error) {
+ return runtimeobs.Target{
+ TenantID: tenant, SessionID: session, EnvironmentID: "22222222-2222-4222-8222-222222222222", Mode: runtimeobs.ModeManaged,
+ Instance: runtimeobs.Instance{AllocationID: "33333333-3333-4333-8333-333333333333", ProviderKey: "provider", AllocationState: "running", ComputePhase: "running"},
+ }, nil
+}
+
+func TestRuntimeObservationPreservesDeclaredProviderReasons(t *testing.T) {
+ raw, err := os.ReadFile("../providercontract/testdata/observation_reasons.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fixture struct {
+ SchemaPattern string `json:"schema_pattern"`
+ Cases []struct {
+ Reason string
+ Valid bool
+ }
+ }
+ if err := json.Unmarshal(raw, &fixture); err != nil {
+ t.Fatal(err)
+ }
+ field, ok := reflect.TypeOf(v1.RuntimeObservation{}).FieldByName("Reason")
+ if !ok || field.Tag.Get("pattern") != fixture.SchemaPattern {
+ t.Fatal("wire reason pattern differs from Provider fixture")
+ }
+ for _, entry := range fixture.Cases {
+ t.Run(entry.Reason, func(t *testing.T) {
+ source := declaredObservationSource{t: t, reason: entry.Reason}
+ service, err := runtimeobs.NewService(source, func(context.Context) (runtimeobs.Source, string, error) { return source, "docker", nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ handler, _, _ := adminTestHandler(t, observeWith(service))
+ session := "11111111-1111-4111-8111-111111111111"
+ response := runtimeObservationRequest(handler, adminSessionsPath+session+"/runtime-observation")
+ if !entry.Valid {
+ if response.Code != http.StatusInternalServerError || strings.Contains(response.Body.String(), entry.Reason) && entry.Reason != "" {
+ t.Fatalf("invalid declaration exposed: %d %s", response.Code, response.Body)
+ }
+ return
+ }
+ var value v1.RuntimeObservation
+ if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &value) != nil {
+ t.Fatalf("observation returned %d: %s", response.Code, response.Body)
+ }
+ if value.Status != "unsupported" || value.Reason == nil || *value.Reason != entry.Reason || value.Mode != "openai_hosted" || value.LifecycleState == nil || *value.LifecycleState != "active" || value.Instance.Kind != "managed_allocation" || value.Instance.AllocationID == nil || *value.Instance.AllocationID != "33333333-3333-4333-8333-333333333333" || value.ObservedAt != nil || value.StartedAt != nil || value.CPU != nil || value.Memory != nil {
+ t.Fatalf("invalid unsupported observation: %s", response.Body)
+ }
+ })
+ }
+}
diff --git a/services/core/internal/providercontract/operations_test.go b/services/core/internal/providercontract/operations_test.go
new file mode 100644
index 000000000..2de0b8db4
--- /dev/null
+++ b/services/core/internal/providercontract/operations_test.go
@@ -0,0 +1,36 @@
+package providercontract
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+)
+
+func TestObservationReasonFixture(t *testing.T) {
+ raw, err := os.ReadFile("testdata/observation_reasons.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fixture struct {
+ Cases []struct {
+ Reason string
+ Valid bool
+ }
+ }
+ if err := json.Unmarshal(raw, &fixture); err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range fixture.Cases {
+ t.Run(entry.Reason, func(t *testing.T) {
+ err := (Support{State: Unsupported, Reason: entry.Reason}).Check("Observe")
+ if errors.Is(err, ErrUnsupported) != entry.Valid || errors.Is(err, ErrContract) == entry.Valid {
+ t.Fatalf("declaration result = %v, valid = %t", err, entry.Valid)
+ }
+ reason, valid := UnsupportedReason(&UnsupportedError{Operation: "Observe", Reason: entry.Reason}, "Observe")
+ if valid != entry.Valid || valid && reason != entry.Reason {
+ t.Fatalf("unsupported result = %q, %t", reason, valid)
+ }
+ })
+ }
+}
diff --git a/services/core/internal/providercontract/testdata/observation_reasons.json b/services/core/internal/providercontract/testdata/observation_reasons.json
new file mode 100644
index 000000000..4ce735dbf
--- /dev/null
+++ b/services/core/internal/providercontract/testdata/observation_reasons.json
@@ -0,0 +1,26 @@
+{
+ "schema_pattern": "^[a-z][a-z0-9_]{0,95}(?![\\s\\S])",
+ "cases": [
+ {"reason": "native_metrics_not_supported", "valid": true, "unavailable": false},
+ {"reason": "a", "valid": true, "unavailable": false},
+ {"reason": "a0_9", "valid": true, "unavailable": false},
+ {"reason": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "valid": true, "unavailable": false},
+ {"reason": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "valid": false, "unavailable": false},
+ {"reason": "", "valid": false, "unavailable": false},
+ {"reason": "A", "valid": false, "unavailable": false},
+ {"reason": "1abc", "valid": false, "unavailable": false},
+ {"reason": "_abc", "valid": false, "unavailable": false},
+ {"reason": "native-metrics", "valid": false, "unavailable": false},
+ {"reason": "native.metrics", "valid": false, "unavailable": false},
+ {"reason": "native metrics", "valid": false, "unavailable": false},
+ {"reason": "native_metrics\n", "valid": false, "unavailable": false},
+ {"reason": "native_metrics\r\n", "valid": false, "unavailable": false},
+ {"reason": "native_metrics\u2028", "valid": false, "unavailable": false},
+ {"reason": "é", "valid": false, "unavailable": false},
+ {"reason": "runtime_mode_not_observable", "valid": true, "unavailable": false},
+ {"reason": "allocation_pending", "valid": true, "unavailable": true},
+ {"reason": "runtime_not_running", "valid": true, "unavailable": true},
+ {"reason": "sample_timeout", "valid": true, "unavailable": true},
+ {"reason": "sample_unavailable", "valid": true, "unavailable": true}
+ ]
+}
diff --git a/services/core/internal/runtimeobs/operations_test.go b/services/core/internal/runtimeobs/operations_test.go
index fc9e242d0..0fc73a2c0 100644
--- a/services/core/internal/runtimeobs/operations_test.go
+++ b/services/core/internal/runtimeobs/operations_test.go
@@ -1,6 +1,10 @@
package runtimeobs
import (
+ "context"
+ "encoding/json"
+ "os"
+ "reflect"
"testing"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract"
@@ -23,3 +27,54 @@ func TestUnsupportedObservationIsNotUnavailable(t *testing.T) {
t.Fatal(observation, err, source.calls)
}
}
+
+// Exercise the existing service classifications, rather than a second list of codes.
+func TestUnavailableReasonsMatchSharedFixture(t *testing.T) {
+ raw, err := os.ReadFile("../providercontract/testdata/observation_reasons.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fixture struct {
+ Cases []struct {
+ Reason string
+ Unavailable bool
+ }
+ }
+ if err := json.Unmarshal(raw, &fixture); err != nil {
+ t.Fatal(err)
+ }
+ expected := map[string]bool{}
+ for _, entry := range fixture.Cases {
+ if entry.Unavailable {
+ expected[entry.Reason] = true
+ }
+ }
+ actual := map[string]bool{}
+ for _, scenario := range []struct {
+ state string
+ resolveErr, observeErr error
+ }{
+ {state: "creating"}, {state: "cleanup_pending"}, {state: "released"},
+ {resolveErr: ErrUnavailable},
+ {state: "running", observeErr: ErrNotRunning},
+ {state: "running", observeErr: context.DeadlineExceeded},
+ {state: "running", observeErr: ErrUnavailable},
+ } {
+ target := Target{EnvironmentID: "environment", Mode: ModeManaged, Instance: Instance{AllocationID: "allocation", ProviderKey: "provider", AllocationState: scenario.state}}
+ if scenario.resolveErr != nil {
+ target.Instance = Instance{}
+ }
+ service, err := NewService(fixedResolver{target: target, err: scenario.resolveErr}, sourceOf(&fixedSource{err: scenario.observeErr}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ observation, err := service.ObserveSession(t.Context(), "tenant", "session")
+ if err != nil || observation.Status != StatusUnavailable {
+ t.Fatalf("classification = %+v, %v", observation, err)
+ }
+ actual[observation.Reason] = true
+ }
+ if !reflect.DeepEqual(actual, expected) {
+ t.Fatalf("unavailable reasons = %v, fixture = %v", actual, expected)
+ }
+}