From c1ef07cb6996c049d8b0e56e7b51762655bcee89 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 13:27:09 +0000 Subject: [PATCH] Accept declared Runtime observation reasons --- .../sessions/SessionRuntimeSection.test.tsx | 39 +++++++++++ .../sessions/SessionRuntimeSection.tsx | 2 +- contracts/agents-api/core.openapi.yaml | 14 +--- .../agents-api/runtime-observability-api.md | 5 +- .../agents-api/v1/runtime_observations.go | 2 +- .../zh/runtime-observability-api.md | 7 +- docs/sandbox-provider.md | 2 +- docs/zh/sandbox-provider.md | 4 +- packages/agents-client/src/client.test.ts | 60 ++++++++++++++++ packages/agents-client/src/client.ts | 9 +-- .../agents-client/src/generated/core-api.ts | 8 +-- .../agents-client/src/protocol-types.test.ts | 6 ++ packages/agents-client/src/types.ts | 14 +++- scripts/patch-agents-openapi.py | 13 ++++ .../internal/api/runtime_observations_test.go | 70 +++++++++++++++++++ .../providercontract/operations_test.go | 36 ++++++++++ .../testdata/observation_reasons.json | 26 +++++++ .../internal/runtimeobs/operations_test.go | 55 +++++++++++++++ 18 files changed, 337 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/features/sessions/SessionRuntimeSection.test.tsx create mode 100644 services/core/internal/providercontract/operations_test.go create mode 100644 services/core/internal/providercontract/testdata/observation_reasons.json 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) + } +}