diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index ec979796d..d2838c838 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -122,7 +122,9 @@ workbench. Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. - **Secrets stay write-only.** Credential tokens, Template environment variables and - setup commands are never returned, to the administrator included. + setup commands are never returned, to the administrator included. An Agent's saved + model provider shows its protocol, base URL, limits and whether a key is configured, + never the key. - **Creators.** Core records the key behind every write. The console shows the creating key of each asset and a project's write history; an asset an administrator copied in an earlier release shows as Admin copy and an asset diff --git a/apps/web/e2e/data/agents.mjs b/apps/web/e2e/data/agents.mjs index 14f77edc9..db6b40bff 100644 --- a/apps/web/e2e/data/agents.mjs +++ b/apps/web/e2e/data/agents.mjs @@ -84,8 +84,28 @@ export const agentDefinitions = [ }, ]; -/** Which project owns each definition (index into the demo projects). */ -export const agentProject = [0, 1, 2, 0, 1, 0, 1, 1, 2, 3]; +/** + * Saved Agents with a model provider, as Core reads them: whether a key is saved, + * never the key. One saved only a provider, so it has no harness. No Session uses + * them, so the demo Sessions stay the same. + */ +export const providerAgentDefinitions = [ + { + id: "agent_11c4f2a8", name: "Spec drafter", model: "MiniMax-M2", reasoning: {}, + instructions: "Draft a product spec from the linked discussion: problem, users, scope, open questions.", + tools: [], metadata: { team: "product" }, + x_agents_core: { model_provider: { protocol: "anthropic", base_url: "https://api.minimaxi.com/anthropic", api_key_configured: true, context_window: 204800, max_output_tokens: 65536 } }, + }, + { + id: "agent_12d7e3b9", name: "Research assistant", model: "kimi-k2", reasoning: {}, + instructions: "Answer research questions with cited sources and a one-paragraph summary.", + tools: [], metadata: { team: "research" }, + x_agents_core: { harness: "codex", model_provider: { protocol: "responses", base_url: "https://api.moonshot.cn/v1", api_key_configured: true } }, + }, +]; + +/** Which project owns each definition (index into the demo projects); the provider Agents come last. */ +export const agentProject = [0, 1, 2, 0, 1, 0, 1, 1, 2, 3, 0, 0]; /** Prompt and reply pairs per Agent so Session transcripts read like real work. */ export const agentConversations = { diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index e031b2c17..6f540bf13 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -1,5 +1,5 @@ // Synthetic deployment data for the browser acceptance fixture: Sessions, Turns, Items, nodes. -import { agentConversations, agentDefinitions } from "./agents.mjs"; +import { agentConversations, agentDefinitions, providerAgentDefinitions } from "./agents.mjs"; let seed = 42; const rand = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296); const pick = (list) => list[Math.floor(rand() * list.length)]; @@ -99,5 +99,11 @@ export function buildDemo(now = Math.floor(Date.now() / 1000)) { const sleeping = allocation.compute_phase === "suspended"; return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, cpu: sleeping ? null : { usage_seconds_total: 600 + Math.floor(rand() * 4000), capacity_cores: 2, usage_cores: Number((rand() * 1.6).toFixed(2)), utilization_ratio: null }, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } }; }); + // Added after everything else is generated, so the seeded data above does not change. + agents.push(...providerAgentDefinitions.map((definition, index) => ({ + ...definition, object: "agent", multi_agent: { enabled: false, max_concurrent_subagents: null }, service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + created_at: now - 86400 * (3 + index), updated_at: now - 1800 * (index + 1), + }))); return { agents, sessions, turns, items, nodes, allocations, observations }; } diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts index 7a79621bf..0de4af8a1 100644 --- a/apps/web/e2e/resources.spec.ts +++ b/apps/web/e2e/resources.spec.ts @@ -26,3 +26,19 @@ test("goes back to the page a resource was opened from", async ({ page, request await page.getByRole("button", { name: "Back", exact: true }).first().click(); await expect(page.getByRole("heading", { name: "Report builder", level: 1 })).toBeVisible(); }); + +test("reads an Agent saved with a model provider and no harness, without its key", async ({ page, request }) => { + // Production holds Spec drafter, saved with only an x_agents_core.model_provider. + await openConsole(page, request, "agents?project=proj_7f3a91c2"); + const open = page.getByRole("button", { name: "Open Spec drafter" }); + await expect(open).toBeVisible(); + await expect(page.getByText(/Could not load/)).toHaveCount(0); + // With one project shown, the cells are model, harness, … + await expect(page.getByRole("row").filter({ has: open }).getByRole("cell").nth(1)).toHaveText("Core default"); + + await open.click(); + await expect(page.getByRole("heading", { name: "Spec drafter", level: 1 })).toBeVisible(); + const provider = page.getByRole("region", { name: "Model provider" }); + for (const text of ["Anthropic Messages", "https://api.minimaxi.com/anthropic", "Configured", "204,800", "65,536"]) await expect(provider).toContainText(text); + await expect(page.locator("body")).not.toContainText(/api_key|\bsk-/); +}); diff --git a/apps/web/src/features/agents/AgentCatalog.css b/apps/web/src/features/agents/AgentCatalog.css index 84f70b95e..09c84c86a 100644 --- a/apps/web/src/features/agents/AgentCatalog.css +++ b/apps/web/src/features/agents/AgentCatalog.css @@ -12,3 +12,8 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* A provider's base URL takes two grid columns so it stays on one line, like an ID. */ +.resource-facts > .agent-provider-url { + grid-column: span 2; +} diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index dba41b4c0..44be05396 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -1,4 +1,4 @@ -import type { CoreHarnessKind, SavedAgent } from "@agents-core-web/agents-client"; +import type { CoreHarnessKind, ModelProviderView, SavedAgent } from "@agents-core-web/agents-client"; import { ArrowLeft, Bot, ListTree, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -24,6 +24,9 @@ export function harnessLabel(harness: CoreHarnessKind): string { return "Codex"; } +/** Provider protocols by their product names, the same in every language. */ +const protocolNames: Record = { anthropic: "Anthropic Messages", responses: "OpenAI Responses" }; + function coverage(summary: ProjectSummary | undefined, locale?: string): string { if (!summary || summary.coverage.sessions === 0) return MISSING; return formatPercent(summary.coverage.reported / summary.coverage.sessions, locale); @@ -201,6 +204,7 @@ function AgentDetail({ projectId, agentId }: { projectId: string; agentId: strin const name = agent ? agent.name || t("catalog.untitled") : agentId; const tools = agent ? toolRows(agent) : []; const metadata = agent ? Object.entries(agent.metadata ?? {}) : []; + const provider = agent?.x_agents_core?.model_provider; return (
{t("view.noTools")}

}
+ {provider ? ( +
+ {/* The read view has no key, only whether one is saved. */} +
+
{t("view.provider.protocol")}
{protocolNames[provider.protocol]}
+
{t("view.provider.baseUrl")}
{provider.base_url}
+
{t("view.provider.apiKey")}
{provider.api_key_configured ? t("view.provider.keyConfigured") : t("view.provider.keyNotConfigured")}
+ {provider.context_window !== undefined ?
{t("view.provider.contextWindow")}
{formatInteger(provider.context_window, locale)}
: null} + {provider.max_output_tokens !== undefined ?
{t("view.provider.maxOutputTokens")}
{formatInteger(provider.max_output_tokens, locale)}
: null} +
+
+ ) : null} +
{t("view.reasoningEffort")}
{agent.reasoning?.effort ?? t("view.coreDefault")}
diff --git a/apps/web/src/i18n/locales/en/agents.ts b/apps/web/src/i18n/locales/en/agents.ts index d673dfb0e..1d7a8af1d 100644 --- a/apps/web/src/i18n/locales/en/agents.ts +++ b/apps/web/src/i18n/locales/en/agents.ts @@ -37,6 +37,17 @@ export const agents = { textFormat: "Text format", verbosity: "Verbosity", serviceTier: "Service tier", + provider: { + title: "Model provider", + help: "Saved on this Agent. Its Sessions use it unless their request sets a provider. Core never returns the API key.", + protocol: "Protocol", + baseUrl: "Base URL", + apiKey: "API key", + keyConfigured: "Configured", + keyNotConfigured: "Not configured", + contextWindow: "Context window", + maxOutputTokens: "Max output tokens", + }, metadata: "Metadata", noMetadata: "No metadata", usage: "Usage", diff --git a/apps/web/src/i18n/locales/zh-CN/agents.ts b/apps/web/src/i18n/locales/zh-CN/agents.ts index 349779c07..11b402c81 100644 --- a/apps/web/src/i18n/locales/zh-CN/agents.ts +++ b/apps/web/src/i18n/locales/zh-CN/agents.ts @@ -30,6 +30,17 @@ export const agents: TranslationShape = { textFormat: "文本格式", verbosity: "详细程度", serviceTier: "服务层级", + provider: { + title: "模型服务", + help: "保存在此 Agent 上。它的 Session 使用这里的模型服务,除非请求中另外指定。Core 从不返回 API key。", + protocol: "协议", + baseUrl: "Base URL", + apiKey: "API key", + keyConfigured: "已配置", + keyNotConfigured: "未配置", + contextWindow: "上下文窗口", + maxOutputTokens: "最大输出 token", + }, metadata: "元数据", noMetadata: "没有元数据", usage: "用量", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 4fc7c1c20..166607a12 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -52,7 +52,7 @@ here. Archived projects remain readable. | Resource | Reads used | Deletion | Creator | Console surface | | --- | --- | --- | --- | --- | -| Agents | `/agents`, `/agents/{agent_id}` | Agent | `agent` | Agents list with usage per Agent; Agent page with instructions, tools, generation settings and metadata | +| Agents | `/agents`, `/agents/{agent_id}` | Agent | `agent` | Agents list with usage per Agent; Agent page with instructions, tools, the saved model provider (never its key), generation settings and metadata | | Environment templates | `/environment-templates`, `/environment-templates/{id}` | Template | `environment_template` | Templates list; Template page with every safe section | | Skills | `/skills`, `/skills/{skill_id}`, `/skills/{skill_id}/versions`, Skill and version `/content` | Skill and Skill version | `skill` (list) | Skills list; Skill page with versions and archive downloads | | Files | `/files` | File | `file` | Files list (metadata only) |