From abd11930d9b14790e9e8dc626c887dacdca93584 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 05:13:21 +0000 Subject: [PATCH 1/2] feat(org): Phase 3 in-host Agents org-ui slice Add host-native Agent Studio under /organization/agents. The page lists lifecycle digital colleagues, compiles openxyos.agent-blueprint.v1, and deep-links to FreeOS Experts / personalization. It does not replace Chat or the personalization editor, and it does not depend on sidecar /api/agent-studio. Co-authored-by: XYAI Labs --- CHANGELOG.md | 3 +- dashboard/src/locales/en.json | 6 +- dashboard/src/locales/zh.json | 6 +- dashboard/src/org-ui/api/createClient.test.ts | 26 + dashboard/src/org-ui/api/createClient.ts | 144 ++++++ dashboard/src/org-ui/contract.ts | 1 + dashboard/src/org-ui/index.ts | 11 + .../org-ui/pages/agents/AgentsPage.module.css | 179 +++++++ .../org-ui/pages/agents/AgentsPage.test.tsx | 130 +++++ .../src/org-ui/pages/agents/AgentsPage.tsx | 460 ++++++++++++++++++ dashboard/src/org-ui/pages/agents/labels.ts | 168 +++++++ .../src/pages/Organization/agents/index.tsx | 42 ++ .../src/pages/Organization/index.test.tsx | 11 + dashboard/src/pages/Organization/index.tsx | 17 + .../src/pages/Organization/orgPathTabs.tsx | 13 +- dashboard/src/routes/controlAdminPath.test.ts | 2 + dashboard/src/routes/index.tsx | 8 + docs/architecture-integration.md | 3 +- docs/org-merge-plan.md | 47 +- scripts/org-export/README.md | 8 +- src/octop/api/openapi_meta.py | 2 +- src/octop/api/routers/org_agents.py | 93 ++++ src/octop/api/routers/org_module.py | 19 +- src/octop/modules/org_os/contract.py | 1 + src/octop/modules/org_os/export_standalone.py | 36 +- tests/unit/cli/test_org_export_standalone.py | 9 + tests/unit/test_org_agents.py | 139 ++++++ 27 files changed, 1557 insertions(+), 27 deletions(-) create mode 100644 dashboard/src/org-ui/pages/agents/AgentsPage.module.css create mode 100644 dashboard/src/org-ui/pages/agents/AgentsPage.test.tsx create mode 100644 dashboard/src/org-ui/pages/agents/AgentsPage.tsx create mode 100644 dashboard/src/org-ui/pages/agents/labels.ts create mode 100644 dashboard/src/pages/Organization/agents/index.tsx create mode 100644 src/octop/api/routers/org_agents.py create mode 100644 tests/unit/test_org_agents.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5b4c58..912b3af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### 新增 -- Organization Phase 3(设置切片):宿主内 Organization Settings。Dashboard `/organization/settings` 使用共享 `dashboard/src/org-ui` 的 `SettingsPage`。本页只编辑 `org_os` 目录模块开关(复用 `GET/PUT /api/org-module/modules`)与组织本地偏好(`GET /api/org-module/settings` · `PUT /api/org-module/prefs`,`{FREEOS_HOME}/org-os/prefs.json`)。**不是** FreeOS 系统设置:大模型密钥、用户、时区仍链到 `/system-settings`。没有边车,也不复制 sidecar 的 AI/用户/数据库页。`freeos org export-standalone` 同时列出 Settings。未迁:Agents、Chat、Workspace、边车系统设置。 +- Organization Phase 3(智能体切片):宿主内 Agent Studio / 智能体定制。Dashboard `/organization/agents` 使用共享 `dashboard/src/org-ui` 的 `AgentsPage`。列表走 `GET /api/org-module/agents`;编译 / 生命周期 / 注册复用已有 `POST /api/org-module/blueprints/compile`、`/employees/transition`、`/employees/spawn`(`{FREEOS_HOME}/tenants//employees/`)。**不是** FreeOS 个性化智能体编辑器,也不是 Chat,也不依赖未挂载的 sidecar `/api/agent-studio/*`。注册后的同事出现在 Experts。`freeos org export-standalone` 同时列出 Agents。未迁:Chat、资料上传、人才市场、薄 Workspace overview。 +- Organization Phase 3(设置切片):宿主内 Organization Settings。Dashboard `/organization/settings` 使用共享 `dashboard/src/org-ui` 的 `SettingsPage`。本页只编辑 `org_os` 目录模块开关(复用 `GET/PUT /api/org-module/modules`)与组织本地偏好(`GET /api/org-module/settings` · `PUT /api/org-module/prefs`,`{FREEOS_HOME}/org-os/prefs.json`)。**不是** FreeOS 系统设置:大模型密钥、用户、时区仍链到 `/system-settings`。没有边车,也不复制 sidecar 的 AI/用户/数据库页。`freeos org export-standalone` 同时列出 Settings。未迁:Chat、Workspace、边车系统设置。 - Organization Phase 3(反思切片):宿主内 Reflections。Dashboard `/organization/reflections` 使用共享 `dashboard/src/org-ui` 的 `ReflectionsPage`。列表/创建/删除走 `/api/org-module/reflections*`,SQLite 在 `{FREEOS_HOME}/org/reflections.sqlite`。可选 `employee_id` / `task_id` 指向同一租户的目录员工与组织任务。这是组织复盘,不是 Chat,也不是第二套技能运行时。`freeos org export-standalone` 同时列出 Reflections。未迁:Chat、边车技能统计、边车部署铁律样例、其余 Open-12。 - Organization Phase 3(任务切片):宿主内 Tasks。Dashboard `/organization/tasks` 与 `/organization/tasks/:id` 使用共享 `dashboard/src/org-ui` 的 `TasksPage` / `TaskDetailPage`。CRUD、状态流转、子任务与人工评论走 `/api/org-module/tasks*`,SQLite 在 `{FREEOS_HOME}/org/tasks.sqlite`。这是组织待办,不是 Octop cron,也不是项目/Chat 对话。`freeos org export-standalone` 同时列出 Tasks。未迁:Chat、附件上传、边车任务库、其余 Open-12。 - Organization Phase 3(知识切片):宿主内 Knowledge。Dashboard `/organization/knowledge` 使用共享 `dashboard/src/org-ui` 的 `KnowledgePage`。列表/详情/笔记走 `/api/org-module/knowledge*`,包装已有 `KnowledgeService`(Octop `knowledge_bases` / `knowledge_documents`),与 Chat 检索同一套行。不克隆 openXYOS sidecar notes/files DB。上传、文件夹、嵌入模型仍在 `/knowledge-bases`。`freeos org export-standalone` 同时列出 Knowledge。未迁:Chat、边车知识库、其余 Open-12。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 9a0ab534..c9ffe118 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -191,6 +191,7 @@ "navTasks": "Tasks", "navReflections": "Reflections", "navSettings": "Settings", + "navAgents": "Agents", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", "openAnnouncements": "Open announcements", @@ -217,7 +218,10 @@ "openReflections": "Open reflections", "settingsTitle": "Organization settings", "settingsBody": "Org-module catalog toggles and local preferences. LLM keys, users, and timezone stay on FreeOS system settings — no sidecar.", - "openSettings": "Open settings" + "openSettings": "Open settings", + "agentsTitle": "Agent Studio", + "agentsBody": "Compile organization digital colleagues from blueprints and manage lifecycle on the host. FreeOS Agents, Experts, and personalization stay where they are — no sidecar Agent Studio and no Chat page.", + "openAgents": "Open Agent Studio" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index b5d43cfe..9a0901a5 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -191,6 +191,7 @@ "navTasks": "任务", "navReflections": "反思", "navSettings": "设置", + "navAgents": "智能体定制", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", "openAnnouncements": "打开通知公告", @@ -217,7 +218,10 @@ "openReflections": "打开反思", "settingsTitle": "组织设置", "settingsBody": "组织模块的目录开关与本地偏好。大模型密钥、用户和时区仍在 FreeOS 系统设置,没有边车。", - "openSettings": "打开设置" + "openSettings": "打开设置", + "agentsTitle": "智能体定制", + "agentsBody": "在宿主内把蓝图编译成组织数字同事并管理生命周期。FreeOS 智能体、专家页和个性化保持原位——没有边车 Agent Studio,也不迁对话页。", + "openAgents": "打开智能体定制" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index a6217b69..d5037132 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -97,6 +97,20 @@ describe("createOrgApiClient", () => { if (path === "/org-module/modules") { return { updates: { settings: true, chat: false } }; } + if (path === "/org-module/agents") { + return { + success: true, + data: { + scope: "org_module", + colleagues: [{ slug: "policy-analyst", lifecycle: "draft" }], + stats: { total: 1, spawned: 0 }, + host_surfaces: { experts: "/experts" }, + }, + }; + } + if (path === "/org-module/blueprints/compile") { + return { slug: "policy-analyst", workspace: "/tmp/policy-analyst" }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -122,6 +136,18 @@ describe("createOrgApiClient", () => { settings: true, chat: false, }); + expect((await client.agents.snapshot()).colleagues[0]?.slug).toBe( + "policy-analyst", + ); + expect( + ( + await client.agents.compile({ + name: "Policy Analyst", + positioning: "Review drafts.", + capabilities: ["Risk alerts"], + }) + ).slug, + ).toBe("policy-analyst"); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 869ee673..c6d7deae 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -543,6 +543,96 @@ export interface OrgSettingsClient { ): Promise>; } +export type OrgColleagueLifecycle = + | "draft" + | "market" + | "recruit" + | "shadow" + | "active" + | "offboard"; + +export interface OrgColleague { + slug: string; + tenant_id: string; + name: string; + lifecycle: OrgColleagueLifecycle | string; + workspace: string; + agent_id?: string; + spawned: boolean; + next: string[]; + created_at?: number; + updated_at?: number; + read_only?: boolean; + cron_enabled?: boolean; +} + +export interface OrgColleagueStats { + total: number; + spawned: number; + by_lifecycle: Record; +} + +export interface OrgAgentHostSurfaces { + experts: string; + personalization: string; + employees: string; + chat: string; + workbench: string; +} + +export interface OrgAgentsSnapshot { + scope: string; + tenant_id: string; + schema: string; + states: string[]; + next_states: Record; + colleagues: OrgColleague[]; + stats: OrgColleagueStats; + host_surfaces: OrgAgentHostSurfaces; + not_on_this_page: string[]; + compile: string; + transition: string; + spawn: string; +} + +export interface OrgBlueprintWrite { + name: string; + positioning: string; + capabilities: string[]; + industry?: string; + experience?: string; + ima_url?: string; +} + +export interface OrgCompiledColleague { + slug: string; + tenant_id: string; + workspace: string; + soul?: string; + skill_files?: string[]; +} + +export interface OrgSpawnedColleague { + agent_id: string; + slug: string; + name: string; + tenant_id: string; + workspace: string; + persisted_to_db?: boolean; + notes?: string[]; +} + +export interface OrgAgentsClient { + snapshot(): Promise; + compile(body: OrgBlueprintWrite): Promise; + transition( + slug: string, + state: string, + reason?: string, + ): Promise; + spawn(slug: string): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; @@ -553,6 +643,7 @@ export interface OrgApiClient { tasks: OrgTasksClient; reflections: OrgReflectionsClient; settings: OrgSettingsClient; + agents: OrgAgentsClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -576,6 +667,7 @@ export function createOrgApiClient(opts: { tasksPrefix?: string; reflectionsPrefix?: string; settingsPrefix?: string; + agentsPrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; const orgPrefix = opts.orgPrefix ?? "/org-module/org"; @@ -585,6 +677,7 @@ export function createOrgApiClient(opts: { const tasksPrefix = opts.tasksPrefix ?? "/org-module/tasks"; const reflectionsPrefix = opts.reflectionsPrefix ?? "/org-module/reflections"; const settingsPrefix = opts.settingsPrefix ?? "/org-module"; + const agentsPrefix = opts.agentsPrefix ?? "/org-module"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -1063,6 +1156,56 @@ export function createOrgApiClient(opts: { }, }; + const agents: OrgAgentsClient = { + async snapshot() { + return unwrap( + await fetchJson>( + `${agentsPrefix}/agents`, + ), + ); + }, + async compile(body) { + const ima = body.ima_url?.trim(); + return fetchJson( + `${agentsPrefix}/blueprints/compile`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + blueprint: { + schema: "openxyos.agent-blueprint.v1", + name: body.name, + positioning: body.positioning, + capabilities: body.capabilities, + industry: body.industry || "professional-services", + experience: body.experience || "", + ima: ima ? { url: ima, status: "linked-unverified" } : undefined, + lifecycle: "draft", + }, + }), + }, + ); + }, + async transition(slug, state, reason) { + return fetchJson(`${agentsPrefix}/employees/transition`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + slug, + state, + reason: reason || "", + }), + }); + }, + async spawn(slug) { + return fetchJson(`${agentsPrefix}/employees/spawn`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug }), + }); + }, + }; + return { announcements, org, @@ -1073,5 +1216,6 @@ export function createOrgApiClient(opts: { tasks, reflections, settings, + agents, }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index 4468cbbc..db74dda1 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -9,6 +9,7 @@ export const SHARED_ORG_UI_MODULES = [ "tasks", "reflections", "settings", + "agents", ] as const; export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index a24352c3..df258df4 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -54,6 +54,15 @@ export type { OrgSettingsClient, OrgSettingsSnapshot, OrgSystemSettingsLinks, + OrgAgentHostSurfaces, + OrgAgentsClient, + OrgAgentsSnapshot, + OrgBlueprintWrite, + OrgColleague, + OrgColleagueLifecycle, + OrgColleagueStats, + OrgCompiledColleague, + OrgSpawnedColleague, OrgSkill, OrgSkillCatalogRow, OrgSkillGenerateResult, @@ -83,6 +92,8 @@ export { ReflectionsPage } from "./pages/reflections/ReflectionsPage"; export type { ReflectionsPageProps } from "./pages/reflections/ReflectionsPage"; export { SettingsPage } from "./pages/settings/SettingsPage"; export type { SettingsPageProps } from "./pages/settings/SettingsPage"; +export { AgentsPage } from "./pages/agents/AgentsPage"; +export type { AgentsPageProps } from "./pages/agents/AgentsPage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/agents/AgentsPage.module.css b/dashboard/src/org-ui/pages/agents/AgentsPage.module.css new file mode 100644 index 00000000..6cc7b921 --- /dev/null +++ b/dashboard/src/org-ui/pages/agents/AgentsPage.module.css @@ -0,0 +1,179 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.subtitle { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + max-width: 680px; + line-height: 1.5; +} + +.hint { + margin: 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + line-height: 1.5; +} + +.callout { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 14px 16px; + background: var(--fn-bg, #f9fafb); + display: flex; + flex-direction: column; + gap: 8px; +} + +.calloutTitle { + margin: 0; + font-size: 13px; + font-weight: 650; +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.card { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 16px; + background: var(--fn-bg-container, #fff); + display: flex; + flex-direction: column; + gap: 12px; +} + +.cardTitle { + margin: 0; + font-size: 15px; + font-weight: 650; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fieldLabel { + font-size: 12px; + font-weight: 600; + color: var(--fn-text-secondary, #667085); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.status { + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.error { + margin: 0; + color: #b42318; + font-size: 13px; +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.stat { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 10px; + padding: 12px; + background: var(--fn-bg-container, #fff); +} + +.statValue { + font-size: 20px; + font-weight: 650; +} + +.statLabel { + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} + +.colleague { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 14px; + background: var(--fn-bg-container, #fff); + display: flex; + flex-direction: column; + gap: 10px; +} + +.colleagueName { + margin: 0; + font-size: 15px; + font-weight: 650; +} + +.meta { + margin: 0; + font-size: 12px; + color: var(--fn-text-secondary, #667085); + word-break: break-all; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.empty { + text-align: center; + padding: 36px 12px; + color: var(--fn-text-secondary, #667085); +} diff --git a/dashboard/src/org-ui/pages/agents/AgentsPage.test.tsx b/dashboard/src/org-ui/pages/agents/AgentsPage.test.tsx new file mode 100644 index 00000000..7f9b58df --- /dev/null +++ b/dashboard/src/org-ui/pages/agents/AgentsPage.test.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AgentsPage } from "./AgentsPage"; +import type { OrgAgentsClient } from "../../api/createClient"; + +function mockClient(): OrgAgentsClient { + return { + snapshot: vi.fn(async () => ({ + scope: "org_module", + tenant_id: "default", + schema: "openxyos.agent-blueprint.v1", + states: ["draft", "market"], + next_states: { draft: ["market", "offboard"] }, + colleagues: [ + { + slug: "policy-analyst", + tenant_id: "default", + name: "policy-analyst", + lifecycle: "draft", + workspace: "/tmp/policy-analyst", + spawned: false, + next: ["market", "offboard"], + }, + ], + stats: { + total: 1, + spawned: 0, + by_lifecycle: { draft: 1, active: 0 }, + }, + host_surfaces: { + experts: "/experts", + personalization: "/personalization", + employees: "/organization/employees", + chat: "/chat", + workbench: "/organization", + }, + not_on_this_page: ["chat_runtime", "sidecar_agent_studio"], + compile: "/api/org-module/blueprints/compile", + transition: "/api/org-module/employees/transition", + spawn: "/api/org-module/employees/spawn", + })), + compile: vi.fn(async () => ({ + slug: "policy-analyst", + tenant_id: "default", + workspace: "/tmp/policy-analyst", + })), + transition: vi.fn(async (slug, state) => ({ + slug, + tenant_id: "default", + name: slug, + lifecycle: state, + workspace: "/tmp/policy-analyst", + spawned: false, + next: [], + })), + spawn: vi.fn(async (slug) => ({ + agent_id: `org-${slug}`, + slug, + name: slug, + tenant_id: "default", + workspace: "/tmp/policy-analyst", + })), + }; +} + +const admin = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +describe("AgentsPage", () => { + it("renders host-native studio without iframe or chat composer", async () => { + const client = mockClient(); + render(); + expect(await screen.findByTestId("org-ui-agents")).toBeInTheDocument(); + expect(screen.getByText("Agent Studio")).toBeInTheDocument(); + expect(screen.getByTestId("org-agents-boundary")).toBeInTheDocument(); + expect(screen.getByTestId("org-agents-experts")).toHaveAttribute( + "href", + "/experts", + ); + expect(screen.getByTestId("org-agents-personalization")).toHaveAttribute( + "href", + "/personalization", + ); + expect( + screen.getByTestId("org-agent-card-policy-analyst"), + ).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(screen.queryByPlaceholderText(/type a message/i)).toBeNull(); + expect(client.snapshot).toHaveBeenCalled(); + }); + + it("compiles a blueprint and advances lifecycle through the injected client", async () => { + const client = mockClient(); + render(); + fireEvent.change(await screen.findByTestId("org-agents-name"), { + target: { value: "Policy Analyst" }, + }); + fireEvent.change(screen.getByTestId("org-agents-positioning"), { + target: { value: "Review drafts with evidence." }, + }); + fireEvent.click(screen.getByText("Risk alerts")); + fireEvent.click(screen.getByTestId("org-agents-compile-submit")); + await waitFor(() => { + expect(client.compile).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Policy Analyst", + positioning: "Review drafts with evidence.", + capabilities: ["Risk alerts"], + }), + ); + }); + fireEvent.click( + screen.getByTestId("org-agent-transition-policy-analyst-market"), + ); + await waitFor(() => { + expect(client.transition).toHaveBeenCalledWith( + "policy-analyst", + "market", + ); + }); + fireEvent.click(screen.getByTestId("org-agent-spawn-policy-analyst")); + await waitFor(() => { + expect(client.spawn).toHaveBeenCalledWith("policy-analyst"); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/agents/AgentsPage.tsx b/dashboard/src/org-ui/pages/agents/AgentsPage.tsx new file mode 100644 index 00000000..b7a95a08 --- /dev/null +++ b/dashboard/src/org-ui/pages/agents/AgentsPage.tsx @@ -0,0 +1,460 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Input, Tag, Typography } from "antd"; +import { Bot, ExternalLink, Plus } from "lucide-react"; +import type { + OrgAgentHostSurfaces, + OrgAgentsClient, + OrgColleague, + OrgColleagueStats, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { agentsLabels, lifecycleLabel } from "./labels"; +import styles from "./AgentsPage.module.css"; + +export interface AgentsPageProps { + client: OrgAgentsClient; + session: OrgSession; + locale: OrgLocale; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; + workbenchHref?: string; +} + +const EMPTY_STATS: OrgColleagueStats = { + total: 0, + spawned: 0, + by_lifecycle: {}, +}; + +const EMPTY_LINKS: OrgAgentHostSurfaces = { + experts: "/experts", + personalization: "/personalization", + employees: "/organization/employees", + chat: "/chat", + workbench: "/organization", +}; + +type CompileForm = { + name: string; + industry: string; + positioning: string; + experience: string; + ima_url: string; + capabilities: string[]; +}; + +const EMPTY_FORM: CompileForm = { + name: "", + industry: "", + positioning: "", + experience: "", + ima_url: "", + capabilities: [], +}; + +export function AgentsPage({ + client, + session, + locale, + workbenchHref = "/organization", +}: AgentsPageProps) { + const labels = agentsLabels(locale); + const [colleagues, setColleagues] = useState([]); + const [stats, setStats] = useState(EMPTY_STATS); + const [links, setLinks] = useState(EMPTY_LINKS); + const [form, setForm] = useState(EMPTY_FORM); + const [draftCap, setDraftCap] = useState(""); + const [loading, setLoading] = useState(true); + const [compiling, setCompiling] = useState(false); + const [busy, setBusy] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const fetchAll = useCallback(async () => { + setLoading(true); + setError(""); + try { + const snap = await client.snapshot(); + setColleagues(snap.colleagues); + setStats(snap.stats); + if (snap.host_surfaces) setLinks(snap.host_surfaces); + } catch { + setColleagues([]); + setStats(EMPTY_STATS); + setError(labels.loadFailed); + } finally { + setLoading(false); + } + }, [client, labels.loadFailed]); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + const addCapability = (value: string) => { + const clean = value.trim(); + if (!clean || form.capabilities.includes(clean)) return; + setForm((prev) => ({ + ...prev, + capabilities: [...prev.capabilities, clean].slice(0, 16), + })); + setDraftCap(""); + }; + + const compile = async () => { + if (!session.isAdmin) return; + const name = form.name.trim(); + const positioning = form.positioning.trim(); + if (!name || !positioning || form.capabilities.length === 0) { + setError(labels.required); + return; + } + setCompiling(true); + setError(""); + setMessage(""); + try { + await client.compile({ + name, + positioning, + capabilities: form.capabilities, + industry: form.industry.trim(), + experience: form.experience.trim(), + ima_url: form.ima_url.trim(), + }); + setForm(EMPTY_FORM); + setMessage(labels.compileDone); + await fetchAll(); + } catch { + setError(labels.compileFailed); + } finally { + setCompiling(false); + } + }; + + const transition = async (slug: string, state: string) => { + if (!session.isAdmin) return; + setBusy(`${slug}:${state}`); + setError(""); + setMessage(""); + try { + await client.transition(slug, state); + await fetchAll(); + } catch { + setError(labels.actionFailed); + } finally { + setBusy(""); + } + }; + + const spawn = async (slug: string) => { + if (!session.isAdmin) return; + setBusy(`spawn:${slug}`); + setError(""); + setMessage(""); + try { + await client.spawn(slug); + setMessage(labels.spawnDone); + await fetchAll(); + } catch { + setError(labels.actionFailed); + } finally { + setBusy(""); + } + }; + + if (loading) { + return ( +
+
{labels.loading}
+
+ ); + } + + return ( +
+
+
+
+ + + {labels.title} + +
+

{labels.subtitle}

+
+ {workbenchHref ? ( + + ) : null} +
+ +
+

{labels.notHereTitle}

+

{labels.notHereBody}

+

{labels.hostHint}

+
+ + + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + {message ? ( +

+ {message} +

+ ) : null} + +
+
+
{stats.total}
+
{labels.total}
+
+
+
{stats.spawned}
+
{labels.spawned}
+
+
+
+ {stats.by_lifecycle.draft ?? 0} +
+
{labels.draft}
+
+
+
+ {stats.by_lifecycle.active ?? 0} +
+
{labels.active}
+
+
+ +
+

{labels.compileTitle}

+

{labels.compileHint}

+ + + + +
+ {labels.fieldCapabilities} +
+ {labels.suggested.map((item) => ( + + ))} +
+ setDraftCap(event.target.value)} + onPressEnter={() => addCapability(draftCap)} + suffix={ +
+ +
+

{labels.governanceTitle}

+

{labels.governanceBody}

+
+
+ + {!session.isAdmin ? ( + {labels.readOnly} + ) : null} +
+
+ +
+

{labels.colleaguesTitle}

+

{labels.colleaguesHint}

+ {colleagues.length === 0 ? ( +
+

{labels.empty}

+

{labels.emptyHint}

+
+ ) : ( +
+ {colleagues.map((row) => ( +
+

+ {row.name || row.slug}{" "} + + {lifecycleLabel(row.lifecycle, labels)} + +

+

+ {labels.slug}: {row.slug} + {row.agent_id ? ` · ${row.agent_id}` : ""} +

+ {row.workspace ? ( +

+ {labels.workspace}: {row.workspace} +

+ ) : null} +
+ {(row.next || []).map((state) => ( + + ))} + {row.spawned ? ( + + ) : ( + + )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/dashboard/src/org-ui/pages/agents/labels.ts b/dashboard/src/org-ui/pages/agents/labels.ts new file mode 100644 index 00000000..85f5ccbb --- /dev/null +++ b/dashboard/src/org-ui/pages/agents/labels.ts @@ -0,0 +1,168 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Agent Studio", + subtitle: + "Compile organization digital colleagues from openxyos.agent-blueprint.v1 and manage their lifecycle on the host.", + hostHint: + "FreeOS Agents, Experts, and the personalization editor stay where they are. This page only compiles blueprints and advances colleague lifecycle.", + notHereTitle: "Not on this page", + notHereBody: + "This is not Chat, not the FreeOS agent editor, and not the unmounted sidecar Agent Studio. Generated colleagues enter draft first; spawn them to appear on Experts.", + openExperts: "FreeOS Experts", + openPersonalization: "Agent personalization", + openEmployees: "Employees directory", + openWorkbench: "Organization workbench", + compileTitle: "Compile a blueprint", + compileHint: + "Writes a tenant workspace (SOUL.md, skills, MEMORY) and registers a draft colleague. No talent-market sidecar, no file-upload studio.", + fieldName: "Colleague name", + namePlaceholder: "e.g. Renewable Energy Due Diligence Advisor", + fieldIndustry: "Industry or domain", + industryPlaceholder: "e.g. renewable energy, construction, healthcare", + fieldPositioning: "Positioning and audience", + positioningPlaceholder: + "Who it helps, which problems it solves, and what it should deliver.", + fieldExperience: "Experience and decision criteria", + experiencePlaceholder: + "Typical cases, counterexamples, and when a human must review.", + fieldCapabilities: "Capabilities", + capabilityPlaceholder: "Add a capability and press Enter", + fieldIma: "Knowledge URL (optional)", + imaPlaceholder: "https://… — stored as linked, unverified", + suggested: [ + "Knowledge lookup", + "Industry Q&A", + "Risk alerts", + "Comparative analysis", + "Report generation", + "Recommendations", + ], + governanceTitle: "Default governance", + governanceBody: + "High-risk conclusions require human review. No outbound send, delete, pay, or production change until the colleague is promoted.", + compile: "Compile draft colleague", + compiling: "Compiling…", + compileDone: "Compiled and registered as draft", + colleaguesTitle: "Digital colleagues", + colleaguesHint: + "Host lifecycle: draft → market → recruit → shadow → active → offboard. Directory employees stay on the Employees page.", + spawn: "Register as FreeOS agent", + spawning: "Registering…", + spawnDone: "Registered for FreeOS chat", + openSpawned: "Open on Experts", + loading: "Loading digital colleagues…", + loadFailed: "Could not load digital colleagues", + compileFailed: "Could not compile the blueprint", + actionFailed: "Could not update the colleague", + required: "Name, positioning, and at least one capability are required.", + empty: "No compiled colleagues yet.", + emptyHint: "Compile a blueprint to create a draft digital colleague.", + total: "Colleagues", + spawned: "Spawned", + draft: "Draft", + market: "Market", + recruit: "Recruit", + shadow: "Shadow", + active: "Active", + offboard: "Offboard", + lifecycle: "Lifecycle", + workspace: "Workspace", + slug: "Slug", + readOnly: "Only administrators can compile or change lifecycle.", +}; + +const ZH: typeof EN = { + title: "智能体定制", + subtitle: + "在宿主内把 openxyos.agent-blueprint.v1 编译成组织数字同事,并管理其生命周期。", + hostHint: + "FreeOS 智能体、专家页和个性化编辑器保持原位。本页只编译蓝图并推进同事生命周期。", + notHereTitle: "本页没有这些", + notHereBody: + "这里不是对话,不是 FreeOS 智能体编辑器,也不是未挂载的边车 Agent Studio。生成结果先进入 draft;注册后才会出现在专家页。", + openExperts: "FreeOS 专家", + openPersonalization: "智能体个性化", + openEmployees: "员工目录", + openWorkbench: "组织工作台", + compileTitle: "编译蓝图", + compileHint: + "写入租户工作区(SOUL.md、技能、MEMORY)并登记为 draft 同事。没有边车人才市场,也没有资料上传工作室。", + fieldName: "同事名称", + namePlaceholder: "例如:新能源项目尽调顾问", + fieldIndustry: "行业 / 领域", + industryPlaceholder: "例如:新能源、建筑、医疗", + fieldPositioning: "定位与服务对象", + positioningPlaceholder: "帮助谁、解决什么问题、交付什么结果。", + fieldExperience: "经验与判断准则", + experiencePlaceholder: "典型案例、反例,以及必须转人工的情形。", + fieldCapabilities: "核心能力", + capabilityPlaceholder: "输入能力后按回车", + fieldIma: "知识库地址(可选)", + imaPlaceholder: "https://… — 保存为已关联、待运行验证", + suggested: [ + "知识库查询", + "行业问答", + "风险提示", + "对比分析", + "报告生成", + "方案建议", + ], + governanceTitle: "默认治理边界", + governanceBody: + "高风险结论必须人工复核。晋升前不外发、删除、支付或改生产环境。", + compile: "编译为草稿同事", + compiling: "正在编译…", + compileDone: "已编译并登记为草稿", + colleaguesTitle: "数字同事", + colleaguesHint: + "宿主生命周期:draft → market → recruit → shadow → active → offboard。目录员工仍在员工目录页。", + spawn: "注册为 FreeOS 智能体", + spawning: "正在注册…", + spawnDone: "已登记到 FreeOS 对话", + openSpawned: "在专家页打开", + loading: "正在加载数字同事…", + loadFailed: "无法加载数字同事", + compileFailed: "无法编译蓝图", + actionFailed: "无法更新同事", + required: "名称、定位和至少一项能力为必填。", + empty: "还没有编译过的同事。", + emptyHint: "编译一份蓝图即可创建草稿数字同事。", + total: "同事", + spawned: "已注册", + draft: "草稿", + market: "市场", + recruit: "招募", + shadow: "见习", + active: "在职", + offboard: "离岗", + lifecycle: "生命周期", + workspace: "工作区", + slug: "标识", + readOnly: "只有管理员可以编译或变更生命周期。", +}; + +export type AgentsLabels = typeof EN; + +export function agentsLabels(locale: OrgLocale): AgentsLabels { + return locale === "zh" ? ZH : EN; +} + +export function lifecycleLabel(state: string, labels: AgentsLabels): string { + switch (state) { + case "draft": + return labels.draft; + case "market": + return labels.market; + case "recruit": + return labels.recruit; + case "shadow": + return labels.shadow; + case "active": + return labels.active; + case "offboard": + return labels.offboard; + default: + return state; + } +} diff --git a/dashboard/src/pages/Organization/agents/index.tsx b/dashboard/src/pages/Organization/agents/index.tsx new file mode 100644 index 00000000..468c5e74 --- /dev/null +++ b/dashboard/src/pages/Organization/agents/index.tsx @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import PageShell from "../../../layouts/PageShell"; +import { + AgentsPage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationAgentsPage() { + const { t, i18n } = useTranslation(); + const user = useCurrentUser(); + const pathTabs = useOrgPathTabs("agents"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + + return ( + + + + ); +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index 44a1ec79..c6dd0048 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -48,6 +48,10 @@ function renderOrg() { path="/organization/settings" element={
settings
} /> + agents} + /> experts} @@ -298,6 +302,13 @@ describe("OrganizationPage", () => { expect(screen.queryByTestId("org-browser-frame")).toBeNull(); }); + it("opens in-host Agent Studio without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-agents")); + expect(await screen.findByTestId("org-agents-page")).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 49185478..11df6c25 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -4,6 +4,7 @@ import { Button, Drawer, Input, Space, Switch, Tag } from "antd"; import { ArrowDownUp, BookOpen, + Bot, Brain, Building2, Download, @@ -532,6 +533,22 @@ export default function OrganizationPage() { {t("organization.openReflections")} +
+ +

+ {t("organization.agentsTitle")} +

+

+ {t("organization.agentsBody")} +

+ +

diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index 41668c5b..6c09eaaf 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -6,6 +6,7 @@ import { Megaphone, Network, Package, + Bot, Settings, Shield, Users, @@ -25,7 +26,8 @@ export function useOrgPathTabs( | "knowledge" | "tasks" | "reflections" - | "settings", + | "settings" + | "agents", ): PathTabsConfig { const { t } = useTranslation(); const navigate = useNavigate(); @@ -82,6 +84,11 @@ export function useOrgPathTabs( label: t("organization.navSettings"), icon: , }, + { + value: "agents", + label: t("organization.navAgents"), + icon: , + }, ], onChange: (value) => { if (value === "announcements") { @@ -120,6 +127,10 @@ export function useOrgPathTabs( navigate("/organization/settings"); return; } + if (value === "agents") { + navigate("/organization/agents"); + return; + } navigate("/organization"); }, }; diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index 5ef220f8..e5ac583d 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -76,6 +76,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/organization/tasks")).toBeNull(); expect(pathPermissionKeys("/organization/reflections")).toBeNull(); expect(pathPermissionKeys("/organization/settings")).toBeNull(); + expect(pathPermissionKeys("/organization/agents")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); expect(pathPermissionKeys("/token-usage")).toBeNull(); @@ -170,6 +171,7 @@ describe("unknown dashboard paths", () => { "organization", ); expect(resolveSelectedKey("/organization/settings")).toBe("organization"); + expect(resolveSelectedKey("/organization/agents")).toBe("organization"); }); it("are caught by the not-found route", () => { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 4ad2b7bc..1905f62c 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -47,6 +47,9 @@ const OrganizationReflectionsPage = lazy( const OrganizationSettingsPage = lazy( () => import("../pages/Organization/settings"), ); +const OrganizationAgentsPage = lazy( + () => import("../pages/Organization/agents"), +); const ProjectsPage = lazy(() => import("../pages/Projects")); // Misc @@ -109,6 +112,7 @@ export const pathToKey: Record = { "/organization/tasks": "organization", "/organization/reflections": "organization", "/organization/settings": "organization", + "/organization/agents": "organization", "/projects": "projects", "/tasks": "projects", "/connectors": "personalization", @@ -283,6 +287,10 @@ export const routeConfigs: RouteConfig[] = [ path: "/organization/settings", element: , }, + { + path: "/organization/agents", + element: , + }, { path: "/projects", element: }, { path: "/tasks", element: }, { diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index cb6f9be1..ff4ac2db 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -13,7 +13,7 @@ FreeOS/Octop remains the execution runtime. - [ADR 001](adr/001-single-process-model.md) — single process, no external queue - [ADR 003](adr/003-org-ui-single-source-dual-delivery.md) — org-ui single source, dual delivery (Accepted) -- [org-merge-plan.md](org-merge-plan.md) — Phases 0–5; Phase 3 in progress (org chart / employees / skills / governance / knowledge / tasks) +- [org-merge-plan.md](org-merge-plan.md) — Phases 0–5; Phase 3 in progress (org chart / employees / skills / governance / knowledge / tasks / reflections / settings / agents) - [asset-loop.md](asset-loop.md) — operator self-growth loop - [ADR index](adr/) @@ -353,6 +353,7 @@ copy the openXYOS working directory themselves. | Org-ui dual-delivery ADR | `docs/adr/003-org-ui-single-source-dual-delivery.md` | | Org merge plan (Phases 0–5) | `docs/org-merge-plan.md` | | BFF router | `src/octop/api/routers/org_module.py` | +| Agent Studio snapshot | `src/octop/api/routers/org_agents.py` · `dashboard/src/org-ui/pages/agents` | | Enablement + health | `src/octop/modules/org_os/service.py` | | Catalog | `src/octop/modules/org_os/catalog.py` | | Proxy + tenant headers | `src/octop/modules/org_os/proxy.py` | diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index 2811f77c..d72ea59f 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,6 +1,6 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 3 组织架构、员工目录、技能、治理 UI、知识、任务、反思与设置切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + 宿主知识库桥接 + `{FREEOS_HOME}/org/tasks.sqlite` + `{FREEOS_HOME}/org/reflections.sqlite` + `{FREEOS_HOME}/org-os/prefs.json` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `/organization/settings` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage / TasksPage / ReflectionsPage / SettingsPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 +**状态:** Phase 3 组织架构、员工目录、技能、治理 UI、知识、任务、反思、设置与智能体切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + 宿主知识库桥接 + `{FREEOS_HOME}/org/tasks.sqlite` + `{FREEOS_HOME}/org/reflections.sqlite` + `{FREEOS_HOME}/org-os/prefs.json` + lifecycle 同事 / 蓝图编译 + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `/organization/settings` + `/organization/agents` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage / TasksPage / ReflectionsPage / SettingsPage / AgentsPage + export-standalone 模块列表)。其余 Open-12 仍待切片。 **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization @@ -25,7 +25,7 @@ Phase 1 冻结了合同。Phase 2(通知公告)与 Phase 3 的组织架构 | **0** 宿主内地基 | `org_os` 控制面、`/api/org-module/*` BFF、Dashboard 原生工作台、sidecar 改为可选 | **已完成**(#55) | | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | | **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **已完成** | -| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:架构 / 员工 / 技能 / 治理 / 知识 / 任务 / 反思 / 设置已迁;智能体、工作台扩展未开始 | +| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:架构 / 员工 / 技能 / 治理 / 知识 / 任务 / 反思 / 设置 / 智能体已迁;工作台扩展(薄 Workspace overview)未开始 | | **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | | **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | @@ -60,7 +60,7 @@ Catalog 十二键(`catalog.py` ↔ `open-module-catalog.ts`): | employees | OpenApp `/employees` | sidecar `/api/employees` + 宿主 lifecycle `/api/org-module/employees` | **Phase 3 切片已落地**:`/organization/employees` 目录 CRUD 复用 `org_chart.sqlite`;lifecycle / spawn **已在宿主,勿重复造** | | skills | OpenApp `/skills` | sidecar `/api/skills` + 宿主 `skill_bridge` | **Phase 3 切片已落地**:`/organization/skills` 列出 `{FREEOS_HOME}/org-skills` 并 generate/publish;宿主 skill packages 只读列出(Agent Skills 运行时仍在个性化)。未迁:边车市场 / 插件中心 / 付费安装 | | **chat** | OpenApp `/chat` | sidecar `/api/chats` | **不迁。** 对话走 FreeOS/Octop | -| agents | OpenApp `/agents`(Agent Studio) | 源码有 `routes/agent-studio.ts`,**`server.ts` 当前未 `app.use`** | Phase 3 前必须先核实挂载;未挂载则只迁 UI、API 在宿主补齐 | +| agents | OpenApp `/agents`(Agent Studio) | 源码有 `routes/agent-studio.ts`,**`server.ts` 当前未 `app.use`** | **Phase 3 切片已落地**:`/organization/agents` 桥接宿主 `GET /api/org-module/agents` + 已有 `/blueprints/compile` 与 employees lifecycle / spawn。**不是** FreeOS 个性化编辑器或 Chat。未迁:边车 `/api/agent-studio/*`、资料上传、人才市场 | | tasks | OpenApp `/tasks` | sidecar `/api/tasks` | **Phase 3 切片已落地**:`/organization/tasks` + `/api/org-module/tasks*`,`{FREEOS_HOME}/org/tasks.sqlite`。不是 Octop cron,也不是项目/Chat。未迁:附件、边车任务库 | | knowledge | OpenApp `/knowledge` | sidecar `/api/knowledge` | **Phase 3 切片已落地**:`/organization/knowledge` 列出宿主 `/api/knowledge-bases` 同一套行;不克隆 sidecar notes/files DB | | reflections | OpenApp `/reflections` | sidecar `/api/reflections` | **Phase 3 切片已落地**:`/organization/reflections` + `/api/org-module/reflections*`,`{FREEOS_HOME}/org/reflections.sqlite`。不是 Chat,也不是第二套技能运行时。未迁:边车技能统计、部署铁律样例 | @@ -115,8 +115,8 @@ uv run freeos org export-standalone --out dist/openxyos-web |---|---|---| | `status` · `catalog` · `overview` · `PATCH ""` · `modules` | 宿主 | 保持 | | `assemble` · `produce` · `pack` · `loop/run` | 宿主 loop | 保持 | -| `employees` · `employees/transition` · `employees/spawn` | 宿主 lifecycle | 保持;与员工 **目录 CRUD** 分开 | -| `assets/*` · `blueprints/compile` · `skills` list/read/generate/publish | 宿主工厂 | **已挂 UI**。list/read 补目录;generate/publish 仍管理员/`plugins` 门控 | +| `employees` · `employees/transition` · `employees/spawn` | 宿主 lifecycle | **已挂 UI**(Agents)。与员工 **目录 CRUD** 分开 | +| `assets/*` · `blueprints/compile` · `skills` list/read/generate/publish | 宿主工厂 | **已挂 UI**。compile 在 Agents;skills list/read 补目录;generate/publish 仍管理员/`plugins` 门控 | | `governance/check\|resolve\|audit` · `pauses` | 宿主 PEP/PDP | **已挂 UI**。`GET /pauses` 补列出待审批;resolve 仍管理员/`plugins` 门控 | | `knowledge` list/read/create/notes | 宿主知识库桥接 | **已挂 UI**。包装 `KnowledgeService`;写操作 `knowledge_bases` 门控。不克隆 sidecar notes DB | | `tasks` list/stats/detail/CRUD/transition/subtasks/comments | 宿主组织待办 | **已挂 UI**。`{FREEOS_HOME}/org/tasks.sqlite`。不是 cron / 项目任务 / Chat | @@ -416,7 +416,7 @@ openXYOS sidecar `/api/governance`(权限矩阵、通信规则、流程模板 | Chat / 从反思页发起会话 | 对话运行时留在 FreeOS/Octop | | 边车 `employee_skill_stats` / 技能使用统计 | 组织技能已有独立切片;本页不另造技能库 | | 边车「系统部署铁律」样例文案 | openXYOS 自身部署备忘,不是组织复盘数据 | -| 其余 Open-12(智能体、工作台扩展) | 后续切片 | +| 其余 Open-12(工作台扩展) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- @@ -449,7 +449,40 @@ openXYOS sidecar `/api/governance`(权限矩阵、通信规则、流程模板 |---|---| | 把系统设置搬进组织页 | FreeOS 已有系统设置;本页只链出去 | | 边车公司/AI/用户/数据库 Tab | sidecar SQL.js;密钥与用户在宿主 | -| Chat / Agents / Workspace 批量搬迁 | 不在本 PR | +| Chat / Workspace 批量搬迁 | 不在本 PR | +| `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | + +--- + +## Phase 3 进度:智能体(本切片) + +按同一缝落地,**只迁组织智能体定制(Agent Studio)**。不替换 FreeOS/Octop 对话运行时,也不把个性化智能体编辑器搬进组织页。 + +### 与 FreeOS Agents / Experts 的关系 + +| 表面 | 存储 | API | 含义 | +|---|---|---|---| +| 组织智能体页 | `{FREEOS_HOME}/tenants//employees/` + `registry.json` | `GET /api/org-module/agents` · 已有 `POST /blueprints/compile` · `/employees/transition` · `/employees/spawn` | 编译 `openxyos.agent-blueprint.v1`,管理数字同事生命周期,注册为宿主聊天智能体 | +| FreeOS Experts / 个性化 | Octop `agents` 表 + 个性化编辑器 | `/experts` · `/personalization` | **运行时编辑与专家目录仍在这里。** 本页只深链 | +| sidecar Agent Studio | `routes/agent-studio.ts` | `/api/agent-studio/*` | **未挂载,不依赖。** 资料上传 / 人才市场不迁 | + +目录员工(人/AI 名册)仍在 `/organization/employees`(`org_chart.sqlite`)。本页管的是 **lifecycle 数字同事**,不是第二套名册。 + +### 已落地 + +1. 壳无关组件:`dashboard/src/org-ui/pages/agents`(`AgentsPage`),数据经 `createOrgApiClient().agents`。 +2. Dashboard 子路由:`/organization/agents`(工作台 path tabs + 入口卡)。 +3. 宿主 API:本切片补 `GET /api/org-module/agents` 快照(同事列表、下一状态、宿主深链)。编译 / 流转 / 注册复用已有 lifecycle 路由;写操作走 `require_permission("plugins")`(管理员绕过)。 +4. 导出骨架:`freeos org export-standalone` 模块列表与 `App.tsx` 同时导入 `AgentsPage`。 + +### 本切片明确推迟 + +| 推迟项 | 原因 | +|---|---| +| Chat / 从智能体页发起会话 | 对话运行时留在 FreeOS/Octop | +| 边车 `/api/agent-studio/*` 资料上传与人才市场 | sidecar 未挂载;本页不克隆上传工作室 | +| 在组织页里编辑 FreeOS 智能体 / 专家 | 运行时已在个性化与 Experts;本页只编译并深链 | +| 其余 Open-12(薄 Workspace overview) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index d8217f6f..f2eae7e0 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -4,7 +4,8 @@ **imports the same org-ui pages** Dashboard mounts under `/organization/...`. Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, -**Governance**, **Knowledge**, **Tasks**, **Reflections**, and **Settings**: +**Governance**, **Knowledge**, **Tasks**, **Reflections**, **Settings**, +and **Agents**: | Delivery | Route | Component | |---|---|---| @@ -28,6 +29,8 @@ Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, | Standalone (this export) | `/reflections` | same import | | Dashboard | `/organization/settings` | `dashboard/src/org-ui` → `SettingsPage` | | Standalone (this export) | `/settings` | same import | +| Dashboard | `/organization/agents` | `dashboard/src/org-ui` → `AgentsPage` | +| Standalone (this export) | `/agents` | same import | Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org chart. Organization skills live under `{FREEOS_HOME}/org-skills/` (skill_bridge). @@ -41,6 +44,9 @@ lessons learned, not Chat and not a second skill runtime. Organization Settings are org-module only: catalog toggles and `{FREEOS_HOME}/org-os/prefs.json`. They do **not** duplicate FreeOS system settings (LLM keys, users, timezone). +Organization Agents compile `openxyos.agent-blueprint.v1` into host +lifecycle colleagues. This is **not** the FreeOS personalization editor, +Chat runtime, or sidecar `/api/agent-studio/*`. Do not copy the pages into a second tree. ## Phase 5 TODO diff --git a/src/octop/api/openapi_meta.py b/src/octop/api/openapi_meta.py index 68eaabf2..caa372a7 100644 --- a/src/octop/api/openapi_meta.py +++ b/src/octop/api/openapi_meta.py @@ -162,7 +162,7 @@ }, { "name": "org-module", - "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, employees, skills, governance, knowledge, tasks, and reflections, sidecar, and growth loop.", + "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, employees, skills, agents, governance, knowledge, tasks, reflections, and settings, sidecar, and growth loop.", }, { "name": "browser", diff --git a/src/octop/api/routers/org_agents.py b/src/octop/api/routers/org_agents.py new file mode 100644 index 00000000..7dd6db72 --- /dev/null +++ b/src/octop/api/routers/org_agents.py @@ -0,0 +1,93 @@ +"""In-host Agent Studio snapshot — digital colleagues + host surface links. + +This is **not** the FreeOS agent editor, Chat runtime, or sidecar +``/api/agent-studio/*``. Compile / transition / spawn stay on the existing +``/blueprints/compile`` and ``/employees/*`` lifecycle routes. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from octop.api.deps import current_user, get_server +from octop.infra.server import OctopServer +from octop.modules.org_os.lifecycle.store import LIFECYCLE_STATES, LifecycleStore +from octop.modules.org_os.lifecycle.transitions import ALLOWED +from octop.modules.org_os.service import org_module_from_paths + +router = APIRouter() + +HOST_SURFACES = { + "experts": "/experts", + "personalization": "/personalization", + "employees": "/organization/employees", + "chat": "/chat", + "workbench": "/organization", +} + +NOT_ON_THIS_PAGE = ( + "chat_runtime", + "personalization_editor", + "sidecar_agent_studio", + "talent_market", +) + + +def _ok(data: Any = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": True} + if data is not None: + payload["data"] = data + return payload + + +def _next_states() -> dict[str, list[str]]: + return {state: sorted(ALLOWED.get(state, frozenset())) for state in LIFECYCLE_STATES} + + +def _colleague_row(record: Any) -> dict[str, Any]: + row = record.to_dict() + row["spawned"] = bool(record.agent_id) + row["next"] = sorted(ALLOWED.get(record.lifecycle, frozenset())) + return row + + +def _snapshot(server: OctopServer) -> dict[str, Any]: + service = org_module_from_paths(server.paths) + tid = service.tenant_id() or "default" + colleagues = [_colleague_row(item) for item in LifecycleStore(service.home, tid).list()] + by_lifecycle: dict[str, int] = dict.fromkeys(LIFECYCLE_STATES, 0) + spawned = 0 + for row in colleagues: + state = str(row.get("lifecycle") or "draft") + by_lifecycle[state] = by_lifecycle.get(state, 0) + 1 + if row.get("spawned"): + spawned += 1 + return { + "scope": "org_module", + "tenant_id": tid, + "schema": "openxyos.agent-blueprint.v1", + "states": list(LIFECYCLE_STATES), + "next_states": _next_states(), + "colleagues": colleagues, + "stats": { + "total": len(colleagues), + "spawned": spawned, + "by_lifecycle": by_lifecycle, + }, + "host_surfaces": dict(HOST_SURFACES), + "not_on_this_page": list(NOT_ON_THIS_PAGE), + "compile": "/api/org-module/blueprints/compile", + "transition": "/api/org-module/employees/transition", + "spawn": "/api/org-module/employees/spawn", + } + + +@router.get("/agents", summary="Organization Agent Studio snapshot") +async def get_org_agents( + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + """List compiled digital colleagues and deep-links to FreeOS agent surfaces.""" + return _ok(_snapshot(server)) diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index fa8ee92d..152e2a3f 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field from octop.api.deps import current_user, get_server, require_permission +from octop.api.routers.org_agents import router as agents_router from octop.api.routers.org_announcements import router as announcements_router from octop.api.routers.org_chart import router as org_chart_router from octop.api.routers.org_knowledge import router as knowledge_router @@ -46,6 +47,7 @@ router.include_router(tasks_router) router.include_router(reflections_router) router.include_router(settings_router) +router.include_router(agents_router) class OrgModulePatch(BaseModel): @@ -555,13 +557,16 @@ async def compile_blueprint_api( else: raise HTTPException(status_code=400, detail="blueprint or blueprint_path required") tid = body.tenant_id or service.tenant_id() or "default" - compiled = compile_blueprint( - source, - home=service.home, - tenant_id=tid, - sidecar_url=service.sidecar_url(), - out_dir=Path(body.out_dir) if body.out_dir else None, - ) + try: + compiled = compile_blueprint( + source, + home=service.home, + tenant_id=tid, + sidecar_url=service.sidecar_url(), + out_dir=Path(body.out_dir) if body.out_dir else None, + ) + except (ValueError, FileNotFoundError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc register_compiled( LifecycleStore(service.home, tid), slug=compiled.slug, diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py index 3e8f6f1d..86eee11e 100644 --- a/src/octop/modules/org_os/contract.py +++ b/src/octop/modules/org_os/contract.py @@ -15,6 +15,7 @@ "tasks", "reflections", "settings", + "agents", ) diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 621a8588..4fe786ec 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -3,8 +3,8 @@ Full Vite + Node server packaging is Phase 5. This writes a snapshot that lists the shared org-ui modules and points at the same AnnouncementPage, OrgChartPage, EmployeesPage, SkillsPage, GovernancePage, KnowledgePage, -TasksPage, ReflectionsPage, and SettingsPage sources Dashboard mounts under -``/organization/...``. +TasksPage, ReflectionsPage, SettingsPage, and AgentsPage sources Dashboard +mounts under ``/organization/...``. """ from __future__ import annotations @@ -34,6 +34,7 @@ | tasks | `/organization/tasks` | `/tasks` | `dashboard/src/org-ui` → `TasksPage` / `TaskDetailPage` | | reflections | `/organization/reflections` | `/reflections` | `dashboard/src/org-ui` → `ReflectionsPage` | | settings | `/organization/settings` | `/settings` | `dashboard/src/org-ui` → `SettingsPage` | +| agents | `/organization/agents` | `/agents` | `dashboard/src/org-ui` → `AgentsPage` | Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. @@ -52,6 +53,10 @@ (`{FREEOS_HOME}/org-os/module-toggles.json`) and org-local prefs (`{FREEOS_HOME}/org-os/prefs.json`). LLM keys, users, timezone, and models stay on FreeOS system settings (`/system-settings`). +Organization Agents compile `openxyos.agent-blueprint.v1` into host +lifecycle colleagues (`{FREEOS_HOME}/tenants//employees/`). This is +**not** the FreeOS personalization editor, Chat runtime, or sidecar +`/api/agent-studio/*`. Spawned colleagues appear on FreeOS Experts. ## Phase 3 vs Phase 5 @@ -60,7 +65,7 @@ - Shared module list (`src/modules.json`) - Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, `EmployeesPage`, `SkillsPage`, `GovernancePage`, `KnowledgePage`, - `TasksPage`, `ReflectionsPage`, and `SettingsPage` + `TasksPage`, `ReflectionsPage`, `SettingsPage`, and `AgentsPage` - Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT Phase 5 (TODO — not implemented here): @@ -81,6 +86,7 @@ _APP_TSX = """\ import { + AgentsPage, AnnouncementPage, EmployeeDetailPage, EmployeesPage, @@ -95,6 +101,7 @@ TasksPage, } from "org-ui"; import type { + OrgAgentsClient, OrgAnnouncementsClient, OrgChartClient, OrgEmployeesClient, @@ -112,8 +119,8 @@ * Page components are the same ones Dashboard mounts at * /organization/announcements, /organization/org, /organization/employees, * /organization/skills, /organization/governance, /organization/knowledge, - * /organization/tasks, /organization/reflections, and - * /organization/settings. + * /organization/tasks, /organization/reflections, + * /organization/settings, and /organization/agents. */ const session: OrgSession = { userId: 0, @@ -132,6 +139,7 @@ tasksClient: OrgTasksClient; reflectionsClient: OrgReflectionsClient; settingsClient: OrgSettingsClient; + agentsClient: OrgAgentsClient; locale?: "zh" | "en"; }) { return ( @@ -204,6 +212,12 @@ locale={props.locale ?? "en"} modules={SHARED_ORG_UI_MODULES} /> + ); } @@ -216,7 +230,7 @@ "description": ( "Standalone Organization web export skeleton. " "Pages come from dashboard/src/org-ui " - "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge + Tasks + Reflections + Settings). " + "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge + Tasks + Reflections + Settings + Agents). " "Full Node packaging is Phase 5." ), "type": "module", @@ -307,6 +321,16 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: "prefs_api": "/api/org-module/prefs", "store": "{FREEOS_HOME}/org-os/prefs.json", }, + "agents": { + "component": "AgentsPage", + "embedded_route": "/organization/agents", + "standalone_route": "/agents", + "api": "/api/org-module/agents", + "compile_api": "/api/org-module/blueprints/compile", + "transition_api": "/api/org-module/employees/transition", + "spawn_api": "/api/org-module/employees/spawn", + "store": "{FREEOS_HOME}/tenants//employees", + }, }, "todo": "Phase 5: full Vite + server packaging; do not fork org-ui pages", } diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index 098b67a7..b2a4ae1f 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -26,6 +26,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "tasks", "reflections", "settings", + "agents", ] modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) assert modules["shared_org_ui_modules"] == [ @@ -38,6 +39,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "tasks", "reflections", "settings", + "agents", ] assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" @@ -69,6 +71,11 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert modules["pages"]["settings"]["modules_api"] == "/api/org-module/modules" assert modules["pages"]["settings"]["prefs_api"] == "/api/org-module/prefs" assert modules["pages"]["settings"]["store"] == "{FREEOS_HOME}/org-os/prefs.json" + assert modules["pages"]["agents"]["component"] == "AgentsPage" + assert modules["pages"]["agents"]["embedded_route"] == "/organization/agents" + assert modules["pages"]["agents"]["api"] == "/api/org-module/agents" + assert modules["pages"]["agents"]["compile_api"] == "/api/org-module/blueprints/compile" + assert modules["pages"]["agents"]["store"] == "{FREEOS_HOME}/tenants//employees" app = (out / "src" / "App.tsx").read_text(encoding="utf-8") assert "AnnouncementPage" in app assert "OrgChartPage" in app @@ -81,6 +88,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "TaskDetailPage" in app assert "ReflectionsPage" in app assert "SettingsPage" in app + assert "AgentsPage" in app assert 'from "org-ui"' in app readme = (out / "README.md").read_text(encoding="utf-8") assert "Phase 5" in readme @@ -93,3 +101,4 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "TasksPage" in readme assert "ReflectionsPage" in readme assert "SettingsPage" in readme + assert "AgentsPage" in readme diff --git a/tests/unit/test_org_agents.py b/tests/unit/test_org_agents.py new file mode 100644 index 00000000..702c571c --- /dev/null +++ b/tests/unit/test_org_agents.py @@ -0,0 +1,139 @@ +"""In-host organization Agent Studio: colleagues + blueprint compile.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from octop.api.deps import current_user, get_server +from octop.api.routers.org_module import router +from octop.infra.users.identity import Role, User +from octop.modules.org_os.contract import ( + SHARED_ORG_UI_MODULES, + assert_shared_modules_in_catalog, +) + + +def _admin() -> User: + return User(id=1, username="admin", role=Role.ADMIN, display_name="Ada") + + +def _member() -> User: + return User(id=2, username="member", role=Role.USER, display_name="Mo") + + +def _client(tmp_path: Path, user: User) -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/org-module") + server = SimpleNamespace( + paths=SimpleNamespace(config=tmp_path / "config.json", root=tmp_path), + services=None, + ) + app.dependency_overrides[current_user] = lambda: user + app.dependency_overrides[get_server] = lambda: server + return TestClient(app) + + +def _blueprint() -> dict[str, object]: + return { + "schema": "openxyos.agent-blueprint.v1", + "name": "Policy Analyst", + "industry": "public-sector", + "positioning": "Help operators review policy drafts with evidence.", + "capabilities": ["Knowledge lookup", "Risk alerts"], + "experience": "Escalate high-risk conclusions.", + } + + +def test_shared_agents_module_is_in_catalog() -> None: + assert "agents" in SHARED_ORG_UI_MODULES + assert_shared_modules_in_catalog() + + +def test_api_snapshot_lists_empty_colleagues(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + snap = admin.get("/api/org-module/agents") + assert snap.status_code == 200 + body = snap.json()["data"] + assert body["scope"] == "org_module" + assert body["schema"] == "openxyos.agent-blueprint.v1" + assert body["colleagues"] == [] + assert body["stats"]["total"] == 0 + assert body["host_surfaces"]["experts"] == "/experts" + assert body["host_surfaces"]["personalization"] == "/personalization" + assert "chat_runtime" in body["not_on_this_page"] + assert "sidecar_agent_studio" in body["not_on_this_page"] + assert body["compile"] == "/api/org-module/blueprints/compile" + assert "draft" in body["states"] + assert "market" in body["next_states"]["draft"] + + +def test_api_member_can_read_snapshot(tmp_path: Path) -> None: + member = _client(tmp_path, _member()) + snap = member.get("/api/org-module/agents") + assert snap.status_code == 200 + assert snap.json()["data"]["colleagues"] == [] + + +def test_compile_registers_draft_colleague(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + compiled = admin.post( + "/api/org-module/blueprints/compile", + json={"blueprint": _blueprint()}, + ) + assert compiled.status_code == 200, compiled.text + payload = compiled.json() + assert payload["slug"] == "policy-analyst" + assert (tmp_path / "tenants" / "default" / "employees" / "policy-analyst" / "SOUL.md").is_file() + + snap = admin.get("/api/org-module/agents").json()["data"] + assert snap["stats"]["total"] == 1 + row = snap["colleagues"][0] + assert row["slug"] == "policy-analyst" + assert row["lifecycle"] == "draft" + assert row["spawned"] is False + assert "market" in row["next"] + + +def test_compile_rejects_invalid_blueprint(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + response = admin.post( + "/api/org-module/blueprints/compile", + json={"blueprint": {"name": "Nope"}}, + ) + assert response.status_code == 400 + assert "positioning" in response.json()["detail"] + + +def test_transition_and_spawn_use_existing_lifecycle_routes(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + assert ( + admin.post( + "/api/org-module/blueprints/compile", + json={"blueprint": _blueprint()}, + ).status_code + == 200 + ) + moved = admin.post( + "/api/org-module/employees/transition", + json={"slug": "policy-analyst", "state": "market"}, + ) + assert moved.status_code == 200, moved.text + assert moved.json()["lifecycle"] == "market" + + spawned = admin.post( + "/api/org-module/employees/spawn", + json={"slug": "policy-analyst"}, + ) + assert spawned.status_code == 200, spawned.text + assert spawned.json()["slug"] == "policy-analyst" + assert spawned.json()["agent_id"] + + snap = admin.get("/api/org-module/agents").json()["data"] + row = snap["colleagues"][0] + assert row["lifecycle"] == "market" + assert row["spawned"] is True + assert snap["stats"]["spawned"] == 1 From b9c22a387f183fcc3a0b3c0e9799d71383cd91ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 05:15:09 +0000 Subject: [PATCH 2/2] fix(org): type Agent Studio colleague snapshot for mypy Co-authored-by: XYAI Labs --- src/octop/api/routers/org_agents.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/octop/api/routers/org_agents.py b/src/octop/api/routers/org_agents.py index 7dd6db72..6ffbeee4 100644 --- a/src/octop/api/routers/org_agents.py +++ b/src/octop/api/routers/org_agents.py @@ -13,7 +13,11 @@ from octop.api.deps import current_user, get_server from octop.infra.server import OctopServer -from octop.modules.org_os.lifecycle.store import LIFECYCLE_STATES, LifecycleStore +from octop.modules.org_os.lifecycle.store import ( + LIFECYCLE_STATES, + ColleagueRecord, + LifecycleStore, +) from octop.modules.org_os.lifecycle.transitions import ALLOWED from octop.modules.org_os.service import org_module_from_paths @@ -46,8 +50,8 @@ def _next_states() -> dict[str, list[str]]: return {state: sorted(ALLOWED.get(state, frozenset())) for state in LIFECYCLE_STATES} -def _colleague_row(record: Any) -> dict[str, Any]: - row = record.to_dict() +def _colleague_row(record: ColleagueRecord) -> dict[str, Any]: + row: dict[str, Any] = record.to_dict() row["spawned"] = bool(record.agent_id) row["next"] = sorted(ALLOWED.get(record.lifecycle, frozenset())) return row