Compare commits

...

12 Commits

Author SHA1 Message Date
figmar 62ed583ff9 docs: add Chinese README as default, keep English as README.en.md, add local modifications section vs upstream 2026-08-10 21:57:45 +08:00
LZH-YS1998 46fd0fed98 Merge pull request #29 from CatJuly/fix/ci-install-dev-deps 2026-08-06 16:01:33 +08:00
LZH-YS1998 a3503e62ba Merge pull request #28 from CatJuly/fix/paper-theme-dialog-contrast 2026-08-06 16:01:33 +08:00
LZH-YS1998 57610e57a6 fix(ui): keep late-approval fast path cross-channel; surface identity errors in chat
PR #27 scoped the lock-free parked-checkpoint answer to exact checkpoint
task/session equality. Company gate cards are raised by role work-item
tasks but answered from the run's anchor chat, whose task id only
appears in payload["task_ids"] — the exact-match guard silently
disabled the fast path for precisely the answers it exists for and
re-opened the project-0012 late-approval lock wedge. Scope by the same
linkage set _find_parked_checkpoint_for_deferred_resume uses (checkpoint
task/session plus payload waiting_task_id/task_ids), keep rejecting
unrelated channels, and keep legacy checkpoints without linkage
deliverable.

The new fail-closed identity errors in _process_session_message raised
out of fire-and-forget background tasks (_track_session), where they are
only logged and the user's message silently vanishes. Surface them as a
visible system chat error and stop instead of raising.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:37:00 +08:00
LZH-YS1998 f6b4fc3683 Merge pull request #27 from cgycorey/fix/org-id-required-fallback
fix(ui): durable custom-org identity for role tasks, replies, approvals, and followups
2026-08-06 15:31:38 +08:00
LZH-YS1998 cf963be079 Merge pull request #26 from cgycorey/feat/llm-reasoning-effort
feat(llm): forward configured reasoning_effort to native LLM calls
2026-08-06 15:31:38 +08:00
CatJuly 75954fac60 fix(ci): install dev extras so pytest is available in smoke workflow
The external-agent-smoke workflow ran `pip install -e .`, which only
installs runtime dependencies. pytest and pytest-timeout live in the
`dev` optional-dependency group in pyproject.toml, so the test step
failed immediately with `No module named pytest` on all three platforms
before running a single test.

Fix: `pip install -e ".[dev]"`

This bug went undetected because every previous PR triggered the workflow
in `action_required` state (fork PRs need maintainer approval to run);
the job never actually executed until now.
2026-08-05 18:05:24 +08:00
CatJuly 2584c45644 fix(ui): bind delete-project dialog to theme tokens so paper theme is readable
The confirm dialog painted its panel with `var(--bg-surface, #1e1e2e)`, but
`--bg-surface` is not defined by any stylesheet in the project. Every theme
therefore fell through to the hardcoded dark `#1e1e2e`.

That went unnoticed under the six dark themes, whose `--text` is light and so
still contrasted against the dark panel. The paper theme is the only light
palette: its `--text` is `#1e293b`, which put dark text on the dark panel at
roughly 1.05:1 contrast and made the title and body invisible.

Point the panel at `--bg-elevated` so it tracks the active palette, and give
the title and body explicit `--text` / `--text-secondary` colors instead of
relying on inherited color plus `opacity: 0.7`, which is equally unreliable
over a light surface. Add a `--border` outline so the now-white panel still
reads as a distinct layer above the scrim.

Verified by hand across all seven themes.
2026-08-05 17:21:40 +08:00
cgycorey bca9b7e1c8 test(llm): cover reasoning effort overrides 2026-08-02 17:52:57 +01:00
cgycorey 15ab07cba2 fix(ui): enforce durable org identity for runtime followups 2026-08-02 17:20:34 +01:00
cgycorey 734a2d5969 fix(ui): preserve durable org identity for runtime approvals 2026-08-01 23:00:32 +01:00
cgycorey 02290b3798 feat(llm): forward configured reasoning_effort to native LLM calls
Add an optional reasoning_effort field to LLMConfig (e.g. low/medium/high/max)
and forward it to litellm.acompletion in both chat() and chat_stream() when
set. Unset by default so non-OpenAI providers are unaffected. Callers can
still override per-call via kwargs.
2026-08-01 11:57:35 +01:00
25 changed files with 3147 additions and 1240 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
with:
python-version: "3.11"
- name: Install package
run: python -m pip install -e .
run: python -m pip install -e ".[dev]"
- name: Run external-agent smoke checks
run: >
python -m pytest -q
+826
View File
@@ -0,0 +1,826 @@
<h1 align="center" style="font-size: 1.75em;">OpenOPC: Build Your Personal AI-Native Company — Self-Built, Self-Run, Self-Grown</h1>
<p align="center">
<b>English</b> | <a href="README.md">简体中文</a>
</p>
🏗️ **Self-Built** — Fully automated to recruit role-specific AI employees and build the org.
⚙️ **Self-Run** — Fully automated to assign tasks, drive handoffs, and keep moving toward your goal.
🌱 **Self-Grown** — Learns from every task, builds organizational memory, always delivers smarter.
<p align="center">
<img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-3776AB?style=flat-square&logo=python&logoColor=white">
<img alt="Office UI" src="https://img.shields.io/badge/Office%20UI-React%20%2B%20Phaser-14b8a6?style=flat-square">
<img alt="CLI and UI" src="https://img.shields.io/badge/interface-CLI%20%2B%20Office%20UI-64748b?style=flat-square">
<img alt="License MIT" src="https://img.shields.io/badge/license-MIT-111827?style=flat-square">
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu" /></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat" /></a>
</p>
![OpenOPC hero banner](docs/assets/chat.png)
## News
- **Jul 14, 2026 — More resilient company runs:** Company-mode sessions now recover and resume more seamlessly while preserving agent identity, shared role context, delegation, and review progress.
- **Jul 13, 2026 — Smoother Office UI:** Faster live updates and chat scrolling improve long-running projects.
- **Jul 8, 2026 — Smarter approvals:** Session grants persist, low-risk actions flow automatically, and deferred decisions stay available.
## Table Of Contents
- [When To Use OpenOPC](#when-to-use-openopc)
- [Demos](#demos)
- [How OpenOPC Works](#how-openopc-works)
- [Quick Start](#quick-start)
- [Office UI Guide](#office-ui-guide)
- [CLI Guide](#cli-guide)
- [Configuration](#configuration)
- [Ecosystem And Sharing](#ecosystem-and-sharing)
- [Roadmap](#roadmap)
- [Acknowledgements](#acknowledgements)
## When to Use OpenOPC
**OpenOPC** covers nine core verticals — from AI development and software engineering to finance, sales, media, e-commerce, and education. Whatever the industry, OpenOPC assembles the right team and delivers end-to-end.
<table>
<tr>
<td width="33%" valign="top">
<br><strong>🤖 AI Tech & Research</strong>
<br><sub>Model training & evaluation, Agent development, LLM apps & AI infrastructure</sub>
</td>
<td width="33%" valign="top">
<br><strong>💻 Software Development</strong>
<br><sub>Android apps, SaaS MVPs, websites, mini programs & game development</sub>
</td>
<td width="33%" valign="top">
<br><strong>📈 Financial Investment</strong>
<br><sub>Investment memos, market maps, due diligence & IC decision packages</sub>
</td>
</tr>
<tr>
<td valign="top">
<strong>🚀 Sales Growth</strong>
<br><sub>Outbound sales, deal strategy, proposals & channel expansion</sub>
</td>
<td valign="top">
<strong>🎬 Content & Media</strong>
<br><sub>Video production, short-form content, scripts, storyboards & multi-platform cuts</sub>
</td>
<td valign="top">
<strong>🤝 Industry Assistants</strong>
<br><sub>Copilots for support, real estate, legal intake, HR onboarding, retail</sub>
</td>
</tr>
<tr>
<td valign="top">
<strong>🧾 Accounting & Finance</strong>
<br><sub>Bookkeeping, financial reporting, tax compliance, budgeting & risk review</sub>
</td>
<td valign="top">
<strong>🛍️ Brand & E-commerce</strong>
<br><sub>Brand planning, product selection, store ops, user growth & retention</sub>
</td>
<td valign="top">
<strong>🎓 Education & Training</strong>
<br><sub>Curriculum design, knowledge base, learner management & content production</sub>
</td>
</tr>
</table>
## Demos
<table>
<tr>
<td width="33%" align="center" valign="top">
<a href="https://youtu.be/XqQeTt6XvPQ">
<img src="https://img.youtube.com/vi/XqQeTt6XvPQ/maxresdefault.jpg" alt="OpenOPC video production demo" width="100%">
</a>
<br><br>
<strong>🎬 Video Production</strong>
</td>
<td width="33%" align="center" valign="top">
<a href="https://drive.google.com/drive/folders/1T1Nl6CCE-cmbGy6sKrYML7_UnP8XID88?usp=drive_link">
<img src="docs/assets/vc-research-package.svg" alt="OpenOPC VC investment research demo" width="100%">
</a>
<br><br>
<strong>📈 Investment Research</strong>
</td>
<td width="33%" align="center" valign="top">
<a href="https://youtu.be/SVc9BvE5ohY">
<img src="https://img.youtube.com/vi/SVc9BvE5ohY/maxresdefault.jpg" alt="OpenOPC game prototype demo" width="100%">
</a>
<br><br>
<strong>🎮 Game Prototype</strong>
</td>
</tr>
</table>
## How OpenOPC Works
OpenOPC assembles a AI company around complex, real-world tasks — through three tightly coupled mechanisms: **Self-Built** staffs the organisation, **Self-Run** executes the work, and **Self-Grown** learns from the outcome.
<p align="center">
<img src="docs/assets/video.png" alt="An OpenOPC company: roles, reporting lines, and the employee staffed into each role" width="100%">
</p>
**1. Self-Built — Staffing the Organisation**
Before any work begins, the right people must be in place. Given a goal, OpenOPC:
- 🌿 Drafts the org chart — deriving the roles and reporting structure the task demands.
- 🎯 Fills each role — a recruiter agent chooses between reusing an existing employee (shaped by prior projects) and onboarding a fresh hire from the talent pool.
💡 Experienced employees carry accumulated context; fresh hires offer a clean slate when a role demands it.
**⚙️ 2. Self-Run — Executing the Work**
With the team assembled, Self-Run orchestrates its members toward a finished deliverable. The central challenge is not raw execution but efficient collaboration under uncertainty, which manifests in two distinct problems.
🔀 Dynamic collaboration orchestration. Real work cannot be fully planned upfront. OpenOPC addresses this through a work-item state machine, where each item's phase determines:
- 📋 Its kanban column — where it stands in the workflow.
- 👑 Its owner — the role responsible at that phase.
- ✅ Its runnability — whether it is ready to proceed.
A manager decomposes items, assigns, and reviews results — accepting, reworking, or escalating — across five modes: execute, delegate, review, integrate, and rework. Decomposition defines a dependency DAG, so:
- ⚡ Independent items proceed in parallel.
- ⏳ Dependent items wait until prerequisites are resolved.
🔗 Dependency resolution and rejection propagate as structured phase transitions, eliminating ad-hoc coordination.
🛡️ Handling blockers surfacing mid-run. Not all obstacles are visible upfront. OpenOPC resolves them at two levels:
- 💬 Within the team — a blocking message pauses the sender, activating the role best positioned to resolve it.
- 📡 Beyond the team — when a blocker exceeds the team's authority, the runtime escalates to the human owner, invoking human judgment precisely when needed.
🖥️ The kanban and office views render this orchestration in real time.
**🌱 3. Self-Grown — Learning from the Run**
Execution generates raw experience; Self-Grown turns it into lasting improvement, guided by two principles.
🏅 Attributing outcomes to the right roles. Crediting the whole company teaches nothing. Instead, OpenOPC:
- 🔍 Resolves user feedback into per-employee evaluations.
- 🎯 Updates only roles that owned the relevant work items — credit and blame land where they were earned.
📖 Distilling trajectories into knowledge. Execution traces are too noisy to learn from. OpenOPC therefore:
- 💡 Distils each role's tasks into high-signal lessons, stored in its private experience profile.
- 📚 Promotes recurring lessons into shared playbooks, which new hires inherit from the outset — compounding organisational knowledge over time.
<details>
<summary><strong>How this maps to the UI</strong></summary>
- `Org -> Team` edits the company architecture and roles.
- `Org -> Employees` hires talent into vacant roles.
- `Team Roster -> Deploy` turns a hired employee into a visible office agent.
- The Workspace composer selects the Task Mode execution agent.
- The role inspector can set runtime policy and preferred external agent for Company Mode roles.
- During execution, Workspace `Agents` and the Execution Progress panel show which role is active, which work item it owns, and which execution agent is doing the concrete work.
</details>
## Quick Start
`uv` is the recommended setup path for OpenOPC. It can install/manage Python, create the project virtualenv, and run commands against that environment without mixing OpenOPC dependencies into your global Python.
OpenOPC requires Python `>=3.10`; the examples below use Python `3.12`.
For direct one-off work, OpenOPC also includes Task Mode, a LobeChat-like single-agent workspace using OpenOPC Native, Codex, Claude Code, Cursor, or OpenCode.
<details open>
<summary><strong>Recommended: uv environment setup</strong></summary>
**macOS**
```bash
# Install uv with Homebrew, or use the official standalone installer.
brew install uv
# curl -LsSf https://astral.sh/uv/install.sh | sh
cd /path/to/OpenOPC
uv python install 3.12
uv venv --python 3.12
source .venv/bin/activate
```
**Linux**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
source "$HOME/.local/bin/env"
cd /path/to/OpenOPC
uv python install 3.12
uv venv --python 3.12
source .venv/bin/activate
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
cd C:\path\to\OpenOPC
uv python install 3.12
uv venv --python 3.12
.\.venv\Scripts\Activate.ps1
```
**Windows Command Prompt**
```bat
winget install --id=astral-sh.uv -e
:: Or run the standalone installer from cmd:
:: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
cd C:\path\to\OpenOPC
uv python install 3.12
uv venv --python 3.12
.venv\Scripts\activate.bat
```
</details>
```bash
# Install OpenOPC into the uv-managed environment
uv pip install -e .
# Optional but recommended for browser tools
uv run python -m playwright install chromium
# Initialize local config, memory, skills, projects, and workspace folders
uv run opc init
# Add an API key in .opc/config/llm_config.yaml
# or configure the env var named by llm.api_key_env.
# Launch the browser UI
uv run opc ui
```
Open `http://localhost:8765` by default.
```bash
# Interactive CLI
uv run opc chat -p demo
# One-shot task mode
uv run opc chat -p demo --mode task --agent codex "Refactor this module and run focused tests"
# Company mode with the built-in Corporate architecture
uv run opc chat -p demo --mode company --company-profile corporate "Plan, implement, review, and document this feature"
# Non-interactive scripting / CI style usage
uv run opc exec -p demo --mode task --agent native --json "Summarize the current repo status"
```
<details>
<summary><strong>Install notes</strong></summary>
- Python: `>=3.10`. Current required dependencies do not all publish Python 3.9-compatible releases.
- `uv` is recommended for local development and release testing. If you prefer classic pip, create and activate a Python `>=3.10` virtualenv, then run `python -m pip install -e .`.
- If virtualenv activation is blocked, stay unactivated and run commands with `uv run ...`.
- See the official [`uv` installation](https://docs.astral.sh/uv/getting-started/installation/) and [Python management](https://docs.astral.sh/uv/guides/install-python/) docs for alternative package managers and managed Python details.
- Node.js: `>=18` is needed when the Office UI frontend must be built.
- `opc ui` auto-installs missing `aiohttp` / `aiosqlite` and auto-builds the frontend if needed.
- If you have not installed external agent CLIs yet, run `opc init --no-external-agent-preflight` to skip the first-run external-agent checks.
- Browser tools are native Playwright tools. Install Chromium with `python -m playwright install chromium` before asking agents to browse pages.
</details>
<details>
<summary><b>Development setup (build from source)</b></summary>
```bash
python -m pip install -e .
python -m pytest
cd opc/plugins/office_ui/frontend_src
npm install
npm run typecheck
npm run build
```
The frontend build output is served from `opc/plugins/office_ui/frontend_dist/`.
</details>
## Office UI Guide
<details>
<summary><b>Expand the Office UI guide — visual tour, workspace, company mode, kanban, office, org</b></summary>
Start it with:
```bash
opc ui
opc ui --port 9000 --project demo
opc ui --rebuild
```
### Visual Tour
Scroll horizontally to browse the Office UI walkthrough. Each screenshot keeps its short guide text attached.
<div style="overflow-x:auto; padding:8px 0 18px;">
<div style="display:flex; gap:18px; min-width:5520px;">
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig1.png" alt="Workspace project, chat, mode, organization, and agent controls" width="900">
<figcaption><strong>Workspace And Setup.</strong> Choose or create a project, start <code>New Chat</code>, then select <code>Company</code> or <code>Task</code> plus the matching organization or agent. In Company Mode, pick role employees and execution agents, or let OpenOPC auto-recruit.</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig2.png" alt="Execution Progress panel showing role status and execution records" width="900">
<figcaption><strong>Execution Progress.</strong> Track every role's state, then click a role or work item to inspect detailed execution records, tool activity, handoffs, reviews, and runtime metadata.</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig3.png" alt="Kanban board showing agent work items and status" width="900">
<figcaption><strong>Kanban.</strong> Supervise each agent's concrete tasks and work items as they move through planning, execution, review, blockers, and completion.</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig4.png" alt="Organization editor for tuning existing organizations and creating new ones" width="900">
<figcaption><strong>Org Control.</strong> Tune existing organizations, adjust roles and reporting lines, review runtime policy, or create a new organization.</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig5.png" alt="Talent market for browsing and recruiting employees" width="900">
<figcaption><strong>Talent Market.</strong> Browse talent templates, inspect candidate details, and recruit employees into vacant roles when the company needs more capability.</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig6.png" alt="Animated office view showing what each role is doing" width="900">
<figcaption><strong>Office View.</strong> Watch the organization as an animated office, with each role/agent showing status, current task, active tool, seat, and runtime activity.</figcaption>
</figure>
</div>
</div>
The Office UI has three primary pages:
| Page | What you do there |
|---|---|
| **Workspace** | Main working surface: session list, kanban board, chat, task details, role progress, comms, and team cockpit. |
| **Office** | Visual office map: agents appear as characters, can be selected, moved, assigned to seats, and inspected. |
| **Org** | Company architecture: switch corporate/saved orgs, create new organizations, edit roles, hire talent, apply architecture presets, and import/export configs. |
### Workspace
The Workspace page is the default screen.
| Area | What to look for |
|---|---|
| Left sidebar | Project sessions, activity, unread counts, and new chat creation. |
| Center board | Kanban cards. In Task Mode, a card is normally one task-backed chat session. In Company Mode, the board follows the selected runtime session and shows delegated work items. |
| Right panel | Context panel with tabs such as `Chat`, `Agents`, `Info`, `Comms`, and `Team`. Collapse, resize, or maximize it while work is running. |
| Composer | Send messages, attach files, choose mode, choose company architecture, and in Task Mode choose the execution agent. |
### Start Work From The UI
1. Create or select a project from the top project selector.
2. In Workspace, click `New Chat`.
3. In the composer, choose `Task` or `Company`.
4. For Task Mode, choose the agent: `OpenOPC Native`, `Codex`, `Claude Code`, `Cursor`, or `OpenCode`.
5. For Company Mode, choose `Corporate` or a saved org architecture.
6. Send the brief.
Once the first message is sent, the mode and task agent are locked for that chat. Use the locked-mode popover to continue in a new chat with a different mode.
### Company Mode In The UI
Company Mode turns one brief into a runtime session plus role-owned work items.
| Tab | What it shows |
|---|---|
| `Chat` | Parent conversation, final responses, runtime progress cards, checkpoint replies, stop/continue/done controls, and links into work-item execution. |
| `Agents` | Role rollup: active/waiting/pending/done roles, current tool, role work items, filters, search, and links to detailed execution progress. |
| `Info` | Status, assignees, role identity, employee assignment, selected execution agent, timing, and developer details. |
| `Comms` | Role inboxes, unread/read/sent messages, meetings, decisions, and recent communication failures. |
| `Team` | Runtime cockpit: teams, seats, approvals, unread communication, run state, and stop controls for the current run. |
To inspect the detailed workflow for a role, open a company-mode session and click a role/work item in the `Chat` progress card or `Agents` tab. The Execution Progress panel shows each work item, its status, activity sections, tool progress, handoffs, review targets, and execution turn metadata.
### Kanban
- Task Mode: the kanban is a project-level board. You can quick-create tasks in `Todo`, start them, and inspect each task from the right panel.
- Company Mode: the active board follows the selected runtime session. Cards represent company work items and move from planning/execution/review/done according to backend runtime state.
- Manual drag between status columns is intentionally restricted when runtime owns the state. Same-column reorder is supported where applicable.
### Office
Use the Office page when you want a visual view of the running team.
- Click an agent character or row to inspect status, current tool, current task, role, office, and seat.
- Use the office/seat controls to move an agent.
- Sub-agents can be shown or hidden.
- Agents created from employees or templates appear in the office and are persisted in `.opc/ui_state.db`.
### Org
The Org page is where company structure becomes runnable.
| Sub-tab | Purpose |
|---|---|
| `Team` | View/edit the role graph, table, role inspector, roster, saved org selector, export package flow, and deploy hired employees to the office. |
| `Runtime` | Tune runtime teams, seats, final decider, delegation strategy, and runtime policy. Corporate is read-only; saved orgs are editable. |
| `Architecture` | Browse built-in architecture presets, preview/apply packages, manage installed packages, and import/export YAML. |
| `Employees` | Search talent templates, view details, hire into vacant roles, and staff the company. |
To create a new company: open `Org`, click `New organization`, enter a name, add at least two members with responsibilities and reporting lines, review, and create. OpenOPC saves it automatically and switches the composer to `Company / <your org>`.
To recruit: import talent templates first, then open `Org -> Employees`, search a template, click `Hire`, choose a vacant role, and deploy the employee from `Team Roster` if you want it visible in the Office page.
```bash
opc talent import /path/to/agency-agents
```
<details>
<summary><strong>Where project files live</strong></summary>
OpenOPC separates runtime/config state from deliverable workspace files.
| Path | Meaning |
|---|---|
| `.opc/config/` | Local config copied from `config/` by `opc init`. |
| `.opc/memory/` | Global and project markdown memory. |
| `.opc/projects/<project>/` | Project runtime metadata and task stores. |
| `.opc/ui_state.db` | Office UI chat, channels, and visual agent state. |
| `../OpenOPC_workplace/<project>/` | Default project workplace. Agents should write durable project files here. |
| `../OpenOPC_workplace/<project>/.opc-comms/` | Internal company-mode comms mailboxes, meetings, and tool-result scratch space. |
Set `OPC_HOME=/path/to/opc-home` if you want config and runtime state outside the repo.
</details>
</details>
## CLI Guide
<details>
<summary><b>Expand the CLI guide — common commands and interactive slash commands</b></summary>
OpenOPC exposes both high-level natural-language commands and lower-level UI/service commands.
Conceptually OpenOPC has two execution modes: `task` and `company`. Some lower-level CLI/service commands still expose `org` as a compatibility selector for Company Mode with a saved organization architecture; in the UI this appears as Company plus an architecture choice.
### Common Commands
```bash
# Chat
opc chat
opc chat -p demo --mode task --agent native "Inspect the failing tests"
opc chat -p demo --mode company --company-profile corporate "Ship this change with review"
# Scriptable execution
opc exec -p demo --mode task --agent codex --stream-json "Run the migration check"
opc exec -p demo --mode company --company-profile corporate "Draft the research report"
# Project lifecycle
opc project list
opc project create demo
opc project switch demo
# Sessions
opc session list -p demo
opc session create "New feature" -p demo --mode company
opc session send <task_id> "Continue with implementation" -p demo
opc session stop <task_id> -p demo
opc session continue <task_id> "Proceed after review" -p demo
# Runtime inspection
opc runtime status -p demo
opc runtime logs <task_id> -p demo
opc work-item list -p demo
opc work-item show <work_item_id> -p demo
opc comms state <task_id> -p demo
# Recruitment
opc talent import /path/to/agency-agents
opc talent hire <template_id> <role_id> -p demo
```
### Interactive Slash Commands
Run `opc chat`, then use slash commands:
```text
/status
/mode task
/mode company corporate
/agent codex
/project switch demo
/session list
/runtime --full
/logs <task_id> --full
/comms <task_id> --full
/org
/talent list
/market list
```
See [`docs/cli-chat-slash.md`](docs/cli-chat-slash.md) for the full command table.
<details>
<summary><strong>CLI command groups</strong></summary>
| Group | Examples |
|---|---|
| `opc project` | `list`, `show`, `create`, `switch`, `rename`, `delete --yes` |
| `opc session` | `list`, `create`, `show`, `config`, `send`, `rename`, `delete --yes`, `stop`, `continue`, `resume`, `complete` |
| `opc mode` | `show`, `set task`, `set company --profile corporate`, `set org --org <id>` for a saved-org company run |
| `opc kanban` | `view`, `task create`, `task update`, `task move`, `task assign`, `task status`, `task delete --yes` |
| `opc agent` | `list`, `create`, `create-from-template`, `import-employee`, `detail`, `move`, `delete --yes` |
| `opc org` | `info`, `export`, `import`, `saved list/save/load/delete`, `role add/update/bulk-add/delete`, `policy update`, `strategy update`, `reset --yes` |
| `opc talent` | `list`, `employees`, `import`, `hire`, `scan`, `import-selected`, `employee-detail`, `import-agent` |
| `opc market` | `presets`, `browse`, `preview`, `apply-preset`, `export`, `install`, `list`, `uninstall --yes` |
| `opc runtime` | `status`, `checkpoints`, `logs`, `run` |
| `opc channels` | `status`, `login`, `start`, `stop` |
Most service-style commands accept `--project/-p` and `--json`.
For saved organization architectures, some CLI/service commands currently use `org` as a compatibility selector even though the conceptual runtime is still Company Mode:
```bash
opc exec -p demo --mode org --org hku_research_lab "Draft the research report"
opc session create "Research sprint" -p demo --mode org --org hku_research_lab
```
</details>
</details>
## Configuration
Run `opc init` once from the repo root. It creates `.opc/`, copies the template config from `config/`, creates memory/skills/log folders, and optionally creates the first project.
<details>
<summary><b>Expand configuration — config files, LLM keys, external agents, channels, browser/MCP, troubleshooting</b></summary>
| File | Purpose |
|---|---|
| `.opc/config/llm_config.yaml` | Default model, LiteLLM/OpenRouter-compatible API base, API key, env var indirection, routing, fallback, temperature, token limit. |
| `.opc/config/system_config.yaml` | Runtime behavior, browser tools, native runtime, compaction, verification, permissions, sandbox, and safety settings. |
| `.opc/config/agent_config.yaml` | External agent command paths, preferred order, model flags, session modes, timeouts, approval modes, and native subagent profiles. |
| `.opc/config/channel_config.yaml` | External messaging providers and credentials. Inbound sender lists are deny-by-default. |
| `.opc/config/company_corporate_config.yaml` | Built-in corporate company architecture template. |
| `.opc/config/company_orgs/org_<id>_config.yaml` | Saved custom company architectures used by Company Mode. |
| `.opc/config/org_index.yaml` | Active saved company architecture selector. |
### LLM Keys
After `opc init`, edit `.opc/config/llm_config.yaml` in the repo-local OPC home. If you set `OPC_HOME`, edit `$OPC_HOME/config/llm_config.yaml` instead.
The template leaves secrets empty. Write your key directly into the file:
```yaml
llm:
default_model: "openai/gpt-5.4"
api_base: "https://openrouter.ai/api/v1"
api_key: "sk-or-v1-..." # your OpenRouter (or other provider) API key
max_tokens: 32768 # max output tokens per request; lower it if your
# model's output cap is smaller
# context_window: 128000 # total input window. Usually auto-detected via
# litellm; unmapped models fall back to 128000.
# Uncomment and set only when the fallback is
# wrong for your model.
```
Then verify with `opc status`.
If you prefer not to store the key in the file, leave `api_key` empty and set `api_key_env` to the name of an environment variable that holds it (e.g. `api_key_env: "OPENROUTER_API_KEY"`).
### Approval & Agent Permissions
The `autonomy` section of `.opc/config/system_config.yaml` controls how much an agent can do without asking. The key knob is `max_auto_approve_risk` — the highest risk level that can be auto-approved:
```yaml
autonomy:
max_auto_approve_risk: medium # low | medium | high | critical
allow_native_tool_auto_approval: true
tool_first_use_approval: true # first use of each tool always asks
```
Every native tool call is risk-classified before it runs: known destructive commands (`rm -rf`, `drop table`, force-push, …) and sensitive keywords (credentials, deploys, …) are `high`/`critical` and always escalate to a human; allowlisted safe prefixes (`ls`, `git status`, …) are `low`; everything else is `medium` and goes through an LLM review before auto-approval.
- `medium` (default): balanced — ordinary commands run without prompts; dangerous ones escalate.
- `low`: strict — anything not on the safe allowlist asks for approval. Recommended for shared or production machines.
- `high`/`critical`: permissive — only for throwaway sandboxes.
The first time a tool is used you are always prompted (unless the tool is in `tool_approval_exemptions`), and your "Always allow" choices accumulate in a per-project allowlist.
### External Agents
Task Mode can explicitly select an execution agent:
```bash
opc chat -p demo --mode task --agent codex "Implement the change"
```
Available values are `native`, `codex`, `claude_code`, `cursor`, and `opencode`. Configure command names, flags, timeouts, session reuse, and approval behavior in `.opc/config/agent_config.yaml`.
In Company Mode, roles can prefer external agents through their role config or the Org role inspector. A role can use `auto`, `native`, or `external` execution strategy, with an optional preferred external agent.
### Feishu Connection
```bash
pip install -e .[channels-feishu]
opc init
opc channels login feishu
```
Edit `.opc/config/channel_config.yaml`:
```yaml
channels:
feishu:
enabled: true
app_id: "cli_xxx"
app_secret: "..."
encrypt_key: ""
verification_token: ""
react_emoji: THUMBSUP
allow_from:
- "ou_xxx"
```
Then:
```bash
opc channels status
opc channels start -p demo
# or run the long-lived engine + channel runtime:
opc run -p demo
```
Feishu uses the `lark-oapi` WebSocket client. `app_id` and `app_secret` are required; `encrypt_key` and `verification_token` are optional unless your tenant/app configuration requires them. Keep `allow_from` explicit; an empty list denies all inbound messages.
<details>
<summary><strong>Other channel providers</strong></summary>
| Provider | Install extra | Runtime | Required fields |
|---|---|---|---|
| Telegram | `channels-telegram` | polling | `token` |
| Slack | `channels-slack` | socket | `bot_token`, `app_token` |
| Discord | `channels-discord` | socket | `token` |
| DingTalk | `channels-dingtalk` | socket | `client_id`, `client_secret` |
| Email | `channels-email` | polling | IMAP/SMTP fields, `consent_granted` |
| Matrix | `channels-matrix` | sync/polling | `homeserver`, `access_token`, `user_id` |
| QQ | `channels-qq` | socket | `app_id`, `secret` |
| WhatsApp | `channels-whatsapp` | bridge | `bridge_url` |
| Mochat | `channels-mochat` | bridge | `base_url`, `claw_token`, `agent_user_id` |
Useful commands:
```bash
opc channels login slack
opc channels status
opc channels start -p demo
opc channels stop
opc run -p demo
```
See [`docs/channels.md`](docs/channels.md) and [`docs/channel-bridges.md`](docs/channel-bridges.md).
</details>
<details>
<summary><strong>Browser tools and MCP servers</strong></summary>
Browser tools:
```bash
python -m playwright install chromium
```
Configure launch behavior in `.opc/config/system_config.yaml`:
```yaml
system:
browser:
mode: embedded # embedded | chrome | auto
headless: true
chrome_channel: chrome
user_data_dir: ""
```
Native browser tools include `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_wait_for`, `browser_scroll`, `browser_select_option`, `browser_evaluate`, `browser_take_screenshot`, and `browser_close`.
MCP servers can be added under `mcp_servers` in `system_config.yaml`. Local servers use stdio commands; remote servers use HTTP/SSE-style URLs. Discovered tools are registered with a server prefix to avoid collisions.
</details>
### Troubleshooting
<details>
<summary><strong>Office UI does not open or looks stale</strong></summary>
```bash
opc ui --rebuild
```
If the browser still shows stale UI state, hard refresh the page. If a previous process died mid-run, restart `opc ui` first so in-memory locks are released.
</details>
<details>
<summary><strong>A task appears stuck</strong></summary>
Start with a server restart and browser hard refresh. If persisted task state is still dirty, use the reset helper:
```bash
python scripts/reset_stuck_task.py --project <project> --session <session_id> --apply
python scripts/reset_stuck_task.py --all --apply
```
</details>
<details>
<summary><strong>External agent is not available</strong></summary>
Run:
```bash
opc status
```
Check `.opc/config/agent_config.yaml` for command names such as `codex`, `claude`, `cursor-agent`, and `opencode`. Disable or reprioritize agents you do not have installed.
</details>
<details>
<summary><strong>Channel provider receives no messages</strong></summary>
Check:
- The provider extra is installed, for example `pip install -e .[channels-feishu]`.
- The provider is `enabled: true`.
- Required credentials are filled.
- `allow_from` contains the sender IDs you expect.
- `opc channels status` reports the provider as configured and available.
</details>
</details>
## Ecosystem And Sharing
Everything OpenOPC builds is yours to keep, reuse, and share — organizations, employees, talent templates, skills, and channels are just files. Import a popular talent library, reuse a team across projects, or package a whole company as a shareable `.opcpkg`.
```bash
# Hire from a talent library (e.g. agency-agents) into a role
opc talent import /path/to/agency-agents
opc talent hire <template_id> <role_id> -p demo
# Reuse or share a whole organization
opc org export --json > my-org.yaml
opc market export --id hku_lab --name "HKU Lab" --output-dir packages
opc market install packages/hku_lab.opcpkg
```
<!--
## Architecture
OpenOPC is a coordination runtime, not just an agent launcher — it separates interaction, organization, execution, tools, memory, and observability into seven layers.
<details>
<summary><b>The seven layers</b></summary>
| Layer | Name | Responsibilities |
|---|---|---|
| 0 | Interaction | CLI, Office UI, message bus, external channel runtime. |
| 1 | Perception & Context | Context loading, routing metadata, context assembly. |
| 2 | Organization | Work-item planning, company runtime, comms, escalation, approval, recovery, recruitment. |
| 3 | Agent Execution | Native runtime, subagents, external agent adapters, permissions, tool planning. |
| 4 | Tools | Shell, file ops, browser, web search, Python execution, git, collaboration tools. |
| 5 | Memory & Evolution | Markdown memory, session compaction, preferences, skill library, talent import. |
| 6 | Observability | Events, cost tracking, structured logs, UI/runtime snapshots. |
</details>
<details>
<summary><b>Core mechanisms</b></summary>
- **Collaboration** — Company Mode compiles a brief into a work-item graph; each role runs in its own session, with reviewers and final deciders as first-class runtime roles. Roles pause on `AWAITING_PEER`, hand off, meet, and pass review/delivery gates — all mirrored to the UI (chat, transcripts, Agents, Comms, Kanban, Execution Progress).
- **Communication** — a file-backed, role-scoped `.opc-comms/` workspace (inboxes, meeting transcripts, shared memory) that can be audited, replayed, and used to wake blocked peers.
- **Self-evolution** — runs feed employee experience, reviewer preferences, checklists, and learned skills into `employee_evolution.json`, so the org improves who it assigns and what context each role gets.
</details>
-->
## Roadmap
OpenOPC is moving quickly. The areas below reflect active development priorities — each grounded in real gaps identified during early usage.
| Area | Planned direction |
|---|---|
| **Role-level skills** | Role config already carries `skill_refs`, and the Org UI surfaces skill metadata today. The next step is letting users select which skills mount to which roles directly from the Org page — feeding into a broader self-evolving skill ecosystem. |
| **Secretary settings** | The secretary will grow into a stronger configuration and memory steward: owning OPC system memory, analysing and comparing projects, and providing guided setup for OpenOPC YAML configuration. |
| **Company-mode channels** | External channels will evolve beyond simple chat entrypoints into richer company-mode workflows — with role-aware notifications, structured approvals, and cross-platform collaboration. |
| **CLI parity** | The CLI is functional today, but the Office UI remains the more complete surface. Upcoming work targets org editing, company-mode inspection, failure recovery, and long-running runtime control from the terminal. |
| **TUI** | A full terminal UI is under consideration once CLI parity matures. The Office UI remains the primary interface in the meantime. |
| **Market and presets** | More architecture presets, recruitable talent packs, import/export workflows, and a package marketplace for sharing and discovering community-built components. |
| **Runtime polish** | Continued improvements to recovery, checkpointing, execution-progress visibility, and visual documentation — making long company runs more observable and resilient. |
## Acknowledgements
OpenOPC is built with gratitude for several open-source projects that helped shape its agent design, skill structure, and talent template ecosystem:
- [openai/codex](https://github.com/openai/codex/) for inspiring practical coding-agent workflows and execution patterns.
- [BloopAI/vibe-kanban](https://github.com/BloopAI/vibe-kanban) for inspiration around kanban-centered agent work management and task visibility.
- [msitarzewski/agency-agents](https://github.com/msitarzewski/agency-agents) for the talent-template foundation. All talent templates included in this repository are imported from `agency-agents`.
- [HKUDS/nanobot](https://github.com/HKUDS/nanobot) for inspiration around skill-oriented agent design and `SKILL.md`-style organization.
- [pixel-agents-hq/pixel-agents](https://github.com/pixel-agents-hq/pixel-agents) for inspiration around the animated pixel-art office visualization of agent activity.
---
<p align="center">
<em> ❤️ Thanks for visiting ✨ OpenOPC!</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenOPC&style=for-the-badge&color=00d4ff"
alt="Views">
</p>
+332 -339
View File
File diff suppressed because it is too large Load Diff
-791
View File
@@ -1,791 +0,0 @@
<h1 align="center" style="font-size: 1.75em;">OpenOPC:打造你的个人 AI 原生公司 — 自建、自营、自成长</h1>
<p align="center">
<a href="README.md">English</a> | <b>简体中文</b>
</p>
🏗️ **自建(Self-Built** — 全自动招募各岗位的 AI 员工,搭建组织架构。
⚙️ **自营(Self-Run** — 全自动分派任务、驱动交接,持续朝你的目标推进。
🌱 **自成长(Self-Grown** — 从每个任务中学习,沉淀组织记忆,交付越来越聪明。
<p align="center">
<img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-3776AB?style=flat-square&logo=python&logoColor=white">
<img alt="Office UI" src="https://img.shields.io/badge/Office%20UI-React%20%2B%20Phaser-14b8a6?style=flat-square">
<img alt="CLI and UI" src="https://img.shields.io/badge/interface-CLI%20%2B%20Office%20UI-64748b?style=flat-square">
<img alt="License MIT" src="https://img.shields.io/badge/license-MIT-111827?style=flat-square">
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat-square&logo=feishu&logoColor=white" alt="Feishu" /></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat-square&logo=wechat&logoColor=white" alt="WeChat" /></a>
</p>
![OpenOPC hero banner](docs/assets/chat.png)
## 目录
- [何时使用 OpenOPC](#何时使用-openopc)
- [演示](#演示)
- [OpenOPC 如何工作](#openopc-如何工作)
- [快速开始](#快速开始)
- [Office UI 指南](#office-ui-指南)
- [CLI 指南](#cli-指南)
- [配置](#配置)
- [生态与分享](#生态与分享)
- [路线图](#路线图)
- [致谢](#致谢)
## 何时使用 OpenOPC
**OpenOPC** 覆盖九大核心垂直领域 — 从 AI 开发、软件工程到金融、销售、媒体、电商与教育。无论哪个行业,OpenOPC 都会组建合适的团队并端到端交付。
<table>
<tr>
<td width="33%" valign="top">
<br><strong>🤖 AI 技术与研究</strong>
<br><sub>模型训练与评估、Agent 开发、LLM 应用与 AI 基础设施</sub>
</td>
<td width="33%" valign="top">
<br><strong>💻 软件开发</strong>
<br><sub>Android 应用、SaaS MVP、网站、小程序与游戏开发</sub>
</td>
<td width="33%" valign="top">
<br><strong>📈 金融投资</strong>
<br><sub>投资备忘录、市场图谱、尽职调查与投决会材料</sub>
</td>
</tr>
<tr>
<td valign="top">
<strong>🚀 销售增长</strong>
<br><sub>外呼销售、交易策略、方案书与渠道拓展</sub>
</td>
<td valign="top">
<strong>🎬 内容与媒体</strong>
<br><sub>视频制作、短视频内容、脚本、分镜与多平台剪辑</sub>
</td>
<td valign="top">
<strong>🤝 行业助理</strong>
<br><sub>客服、房产、法律咨询、HR 入职、零售等场景的 Copilot</sub>
</td>
</tr>
<tr>
<td valign="top">
<strong>🧾 会计与财务</strong>
<br><sub>记账、财务报告、税务合规、预算与风险审查</sub>
</td>
<td valign="top">
<strong>🛍️ 品牌与电商</strong>
<br><sub>品牌规划、选品、店铺运营、用户增长与留存</sub>
</td>
<td valign="top">
<strong>🎓 教育与培训</strong>
<br><sub>课程设计、知识库、学员管理与内容生产</sub>
</td>
</tr>
</table>
## 演示
<table>
<tr>
<td width="33%" align="center" valign="top">
<a href="https://youtu.be/XqQeTt6XvPQ">
<img src="https://img.youtube.com/vi/XqQeTt6XvPQ/maxresdefault.jpg" alt="OpenOPC 视频制作演示" width="100%">
</a>
<br><br>
<strong>🎬 视频制作</strong>
</td>
<td width="33%" align="center" valign="top">
<a href="https://drive.google.com/drive/folders/1T1Nl6CCE-cmbGy6sKrYML7_UnP8XID88?usp=drive_link">
<img src="docs/assets/vc-research-package.svg" alt="OpenOPC VC 投资研究演示" width="100%">
</a>
<br><br>
<strong>📈 投资研究</strong>
</td>
<td width="33%" align="center" valign="top">
<a href="https://youtu.be/SVc9BvE5ohY">
<img src="https://img.youtube.com/vi/SVc9BvE5ohY/maxresdefault.jpg" alt="OpenOPC 游戏原型演示" width="100%">
</a>
<br><br>
<strong>🎮 游戏原型</strong>
</td>
</tr>
</table>
## OpenOPC 如何工作
OpenOPC 围绕复杂的真实任务组建一家 AI 公司 — 通过三个紧密耦合的机制:**自建**负责组织配员,**自营**负责执行工作,**自成长**负责从结果中学习。
<p align="center">
<img src="docs/assets/video.png" alt="一家 OpenOPC 公司:角色、汇报关系,以及每个角色配备的员工" width="100%">
</p>
**1. 自建 — 为组织配员**
在任何工作开始之前,必须先把合适的人放到合适的位置。给定一个目标,OpenOPC 会:
- 🌿 起草组织架构图 — 从任务需求推导出所需的角色与汇报结构。
- 🎯 填补每个角色 — 由招聘 Agent 在「复用现有员工(带着以往项目塑造的经验)」与「从人才池中招募新人」之间做出选择。
💡 有经验的员工携带积累的上下文;当角色需要时,新员工则提供一张白纸。
**⚙️ 2. 自营 — 执行工作**
团队组建完成后,自营机制协调成员产出最终交付物。核心挑战不在于单纯执行,而在于不确定性下的高效协作,具体体现为两个问题。
🔀 动态协作编排。真实工作无法完全提前规划。OpenOPC 通过工作项状态机来解决,每个工作项所处的阶段决定:
- 📋 它在看板的哪一列 — 处于工作流的哪个位置。
- 👑 它的负责人 — 该阶段由哪个角色负责。
- ✅ 它的可执行性 — 是否已经具备推进条件。
管理者负责拆解工作项、分派并评审结果 — 接受、返工或上报 — 覆盖五种模式:执行(execute)、委派(delegate)、评审(review)、集成(integrate)与返工(rework)。拆解定义了一个依赖 DAG,因此:
- ⚡ 相互独立的工作项并行推进。
- ⏳ 有依赖的工作项等待前置项完成。
🔗 依赖解除与驳回都作为结构化的阶段转换传播,消除了临时的人为协调。
🛡️ 处理运行中途出现的阻塞。并非所有障碍都能提前预见。OpenOPC 在两个层面解决:
- 💬 团队内部 — 一条阻塞消息会暂停发送者,并激活最适合解决该问题的角色。
- 📡 团队之外 — 当阻塞超出团队权限时,运行时会上报给人类所有者,在真正需要时引入人类判断。
🖥️ 看板与办公室视图实时呈现这一编排过程。
**🌱 3. 自成长 — 从运行中学习**
执行产生原始经验;自成长把它转化为持久的改进,遵循两条原则。
🏅 把结果归因到正确的角色。把功劳记给整个公司学不到任何东西。因此 OpenOPC:
- 🔍 将用户反馈解析为针对每位员工的评估。
- 🎯 只更新负责了相关工作项的角色 — 功与过都落到应得之处。
📖 把执行轨迹提炼为知识。执行轨迹噪声太大,无法直接学习。因此 OpenOPC:
- 💡 把每个角色的任务提炼为高信号的经验教训,存入其私有经验档案。
- 📚 把反复出现的经验提升为共享的作业手册(playbook),新员工从入职起即可继承 — 让组织知识随时间复利增长。
<details>
<summary><strong>这些机制如何对应到 UI</strong></summary>
- `Org -> Team` 编辑公司架构与角色。
- `Org -> Employees` 为空缺角色招募人才。
- `Team Roster -> Deploy` 把已录用的员工变成办公室中可见的 Agent。
- Workspace 输入框可选择 Task 模式的执行 Agent。
- 角色检查器可为 Company 模式的角色设置运行时策略与偏好的外部 Agent。
- 执行期间,Workspace 的 `Agents` 页签与 Execution Progress 面板会显示哪个角色处于活动状态、它负责哪个工作项、以及由哪个执行 Agent 完成具体工作。
</details>
## 快速开始
推荐使用 `uv` 来安装 OpenOPC。它可以安装/管理 Python、创建项目虚拟环境,并在该环境中运行命令,而不会把 OpenOPC 的依赖混入全局 Python。
OpenOPC 要求 Python `>=3.10`;下面的示例使用 Python `3.12`
对于直接的一次性工作,OpenOPC 还提供 Task 模式 — 一个类 LobeChat 的单 Agent 工作台,可使用 OpenOPC Native、Codex、Claude Code、Cursor 或 OpenCode。
<details open>
<summary><strong>推荐:uv 环境搭建</strong></summary>
**macOS**
```bash
# 使用 Homebrew 安装 uv,或使用官方独立安装脚本。
brew install uv
# curl -LsSf https://astral.sh/uv/install.sh | sh
cd /path/to/OpenOPC
uv python install 3.12
uv venv --python 3.12
source .venv/bin/activate
```
**Linux**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
source "$HOME/.local/bin/env"
cd /path/to/OpenOPC
uv python install 3.12
uv venv --python 3.12
source .venv/bin/activate
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
cd C:\path\to\OpenOPC
uv python install 3.12
uv venv --python 3.12
.\.venv\Scripts\Activate.ps1
```
**Windows 命令提示符**
```bat
winget install --id=astral-sh.uv -e
:: 或在 cmd 中运行独立安装脚本:
:: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
cd C:\path\to\OpenOPC
uv python install 3.12
uv venv --python 3.12
.venv\Scripts\activate.bat
```
</details>
```bash
# 将 OpenOPC 安装到 uv 管理的环境中
uv pip install -e .
# 可选但推荐:安装浏览器工具所需的 Chromium
uv run python -m playwright install chromium
# 初始化本地配置、记忆、技能、项目与工作区目录
uv run opc init
# 在 .opc/config/llm_config.yaml 中填入 API key
# 或配置 llm.api_key_env 指定的环境变量。
# 启动浏览器 UI
uv run opc ui
```
默认打开 `http://localhost:8765`
```bash
# 交互式 CLI
uv run opc chat -p demo
# 一次性 Task 模式
uv run opc chat -p demo --mode task --agent codex "Refactor this module and run focused tests"
# 使用内置 Corporate 架构的 Company 模式
uv run opc chat -p demo --mode company --company-profile corporate "Plan, implement, review, and document this feature"
# 非交互脚本 / CI 风格用法
uv run opc exec -p demo --mode task --agent native --json "Summarize the current repo status"
```
<details>
<summary><strong>安装说明</strong></summary>
- Python`>=3.10`。当前必需依赖并非全部提供兼容 Python 3.9 的版本。
- 本地开发与发布测试推荐使用 `uv`。如果你偏好经典 pip,请创建并激活一个 Python `>=3.10` 的虚拟环境,然后运行 `python -m pip install -e .`
- 如果虚拟环境激活被阻止,可以不激活,直接用 `uv run ...` 运行命令。
- 关于其他包管理器与托管 Python 的细节,参见官方 [`uv` 安装文档](https://docs.astral.sh/uv/getting-started/installation/) 与 [Python 管理文档](https://docs.astral.sh/uv/guides/install-python/)。
- Node.js:需要构建 Office UI 前端时要求 `>=18`
- `opc ui` 会自动安装缺失的 `aiohttp` / `aiosqlite`,并在需要时自动构建前端。
- 如果你尚未安装外部 Agent CLI,运行 `opc init --no-external-agent-preflight` 可跳过首次运行的外部 Agent 检查。
- 浏览器工具基于原生 Playwright。在让 Agent 浏览网页之前,先用 `python -m playwright install chromium` 安装 Chromium。
</details>
<details>
<summary><b>开发环境搭建(从源码构建)</b></summary>
```bash
python -m pip install -e .
python -m pytest
cd opc/plugins/office_ui/frontend_src
npm install
npm run typecheck
npm run build
```
前端构建产物从 `opc/plugins/office_ui/frontend_dist/` 提供服务。
</details>
## Office UI 指南
<details>
<summary><b>展开 Office UI 指南 — 视觉导览、工作台、Company 模式、看板、办公室、组织</b></summary>
启动方式:
```bash
opc ui
opc ui --port 9000 --project demo
opc ui --rebuild
```
### 视觉导览
横向滚动浏览 Office UI 演示。每张截图都附有简短的说明文字。
<div style="overflow-x:auto; padding:8px 0 18px;">
<div style="display:flex; gap:18px; min-width:5520px;">
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig1.png" alt="Workspace 的项目、聊天、模式、组织与 Agent 控件" width="900">
<figcaption><strong>工作台与初始设置。</strong>选择或创建项目,点击 <code>New Chat</code>,然后选择 <code>Company</code> 或 <code>Task</code> 以及对应的组织或 Agent。在 Company 模式下,可以指定角色员工与执行 Agent,也可以让 OpenOPC 自动招募。</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig2.png" alt="Execution Progress 面板显示角色状态与执行记录" width="900">
<figcaption><strong>执行进度。</strong>跟踪每个角色的状态,点击角色或工作项即可查看详细的执行记录、工具活动、交接、评审与运行时元数据。</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig3.png" alt="看板展示 Agent 的工作项与状态" width="900">
<figcaption><strong>看板。</strong>监督每个 Agent 的具体任务与工作项,观察它们在规划、执行、评审、阻塞与完成之间流转。</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig4.png" alt="组织编辑器,可调整现有组织或新建组织" width="900">
<figcaption><strong>组织管理。</strong>调整现有组织、修改角色与汇报关系、查看运行时策略,或创建一个新组织。</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig5.png" alt="人才市场,可浏览并招募员工" width="900">
<figcaption><strong>人才市场。</strong>浏览人才模板,查看候选人详情,在公司需要更多能力时把员工招募到空缺角色上。</figcaption>
</figure>
<figure style="flex:0 0 900px; width:900px; margin:0;">
<img src="docs/assets/fig6.png" alt="动画办公室视图,展示每个角色正在做什么" width="900">
<figcaption><strong>办公室视图。</strong>以动画办公室的形式观察整个组织,每个角色/Agent 都会显示状态、当前任务、正在使用的工具、座位与运行时活动。</figcaption>
</figure>
</div>
</div>
Office UI 有三个主要页面:
| 页面 | 在这里做什么 |
|---|---|
| **Workspace** | 主要工作界面:会话列表、看板、聊天、任务详情、角色进度、通讯与团队驾驶舱。 |
| **Office** | 可视化办公室地图:Agent 以角色形象出现,可以选中、移动、分配座位与查看详情。 |
| **Org** | 公司架构:切换 Corporate/已保存的组织、创建新组织、编辑角色、招募人才、应用架构预设、导入/导出配置。 |
### 工作台(Workspace
Workspace 页面是默认界面。
| 区域 | 关注点 |
|---|---|
| 左侧边栏 | 项目会话、活动、未读计数与新建聊天。 |
| 中间看板 | 看板卡片。Task 模式下,一张卡片通常对应一个任务型聊天会话。Company 模式下,看板跟随所选的运行时会话,展示已委派的工作项。 |
| 右侧面板 | 上下文面板,包含 `Chat``Agents``Info``Comms``Team` 等页签。工作运行期间可以折叠、调整大小或最大化。 |
| 输入框 | 发送消息、附加文件、选择模式、选择公司架构;在 Task 模式下选择执行 Agent。 |
### 从 UI 开始工作
1. 在顶部项目选择器中创建或选择一个项目。
2. 在 Workspace 中点击 `New Chat`
3. 在输入框中选择 `Task``Company`
4. Task 模式下选择 Agent`OpenOPC Native``Codex``Claude Code``Cursor``OpenCode`
5. Company 模式下选择 `Corporate` 或一个已保存的组织架构。
6. 发送任务简报。
第一条消息发出后,该聊天的模式与任务 Agent 即被锁定。若需换用其他模式,可通过锁定模式的弹出提示在新聊天中继续。
### UI 中的 Company 模式
Company 模式把一份简报变成一个运行时会话加一组由角色负责的工作项。
| 页签 | 展示内容 |
|---|---|
| `Chat` | 父级对话、最终回复、运行时进度卡片、检查点回复、停止/继续/完成控件,以及跳转到工作项执行的链接。 |
| `Agents` | 角色汇总:活动/等待/待定/完成的角色、当前工具、角色工作项、筛选、搜索,以及详细执行进度的链接。 |
| `Info` | 状态、负责人、角色身份、员工分配、所选执行 Agent、时间信息与开发者详情。 |
| `Comms` | 角色收件箱、未读/已读/已发消息、会议、决策与最近的通讯故障。 |
| `Team` | 运行时驾驶舱:团队、座位、审批、未读通讯、恢复状态与当前运行的停止控件。 |
要查看某个角色的详细工作流,打开一个 Company 模式会话,在 `Chat` 进度卡片或 `Agents` 页签中点击角色/工作项。Execution Progress 面板会展示每个工作项及其状态、活动分区、工具进度、交接、评审对象与执行轮次元数据。
### 看板(Kanban
- Task 模式:看板是项目级面板。可以在 `Todo` 中快速创建任务、启动任务,并从右侧面板查看每个任务。
- Company 模式:当前面板跟随所选运行时会话。卡片代表公司工作项,按照后端运行时状态在规划/执行/评审/完成之间流转。
- 当运行时掌管状态时,跨状态列的手动拖拽会被有意限制。同列内重新排序在适用时是支持的。
### 办公室(Office
当你想以可视化方式查看运行中的团队时,使用 Office 页面。
- 点击 Agent 角色形象或列表行,查看状态、当前工具、当前任务、角色、办公室与座位。
- 使用办公室/座位控件移动 Agent。
- 子 Agent 可以显示或隐藏。
- 由员工或模板创建的 Agent 会出现在办公室中,并持久化在 `.opc/ui_state.db`
### 组织(Org
Org 页面是公司结构变得可运行的地方。
| 子页签 | 用途 |
|---|---|
| `Team` | 查看/编辑角色图谱、表格、角色检查器、花名册、已保存组织选择器、导出打包流程,并把已录用员工部署到办公室。 |
| `Runtime` | 调整运行时团队、座位、最终决策者、委派策略与运行时策略。Corporate 为只读;已保存的组织可编辑。 |
| `Architecture` | 浏览内置架构预设、预览/应用包、管理已安装的包、导入/导出 YAML。 |
| `Employees` | 搜索人才模板、查看详情、招募到空缺角色、为公司配员。 |
创建新公司:打开 `Org`,点击 `New organization`,输入名称,添加至少两名带职责与汇报关系的成员,检查并创建。OpenOPC 会自动保存,并把输入框切换为 `Company / <你的组织>`
招募:先导入人才模板,然后打开 `Org -> Employees`,搜索模板,点击 `Hire`,选择一个空缺角色;若希望员工出现在 Office 页面,再从 `Team Roster` 部署。
```bash
opc talent import /path/to/agency-agents
```
<details>
<summary><strong>项目文件的位置</strong></summary>
OpenOPC 把运行时/配置状态与交付物工作区文件分开存放。
| 路径 | 含义 |
|---|---|
| `.opc/config/` | 由 `opc init``config/` 复制而来的本地配置。 |
| `.opc/memory/` | 全局与项目级 Markdown 记忆。 |
| `.opc/projects/<project>/` | 项目运行时元数据与任务存储。 |
| `.opc/ui_state.db` | Office UI 的聊天、频道与可视化 Agent 状态。 |
| `../OpenOPC_workplace/<project>/` | 默认项目工作区。Agent 应把持久的项目文件写到这里。 |
| `../OpenOPC_workplace/<project>/.opc-comms/` | Company 模式内部通讯信箱、会议与工具结果暂存区。 |
若希望配置与运行时状态放在仓库之外,设置 `OPC_HOME=/path/to/opc-home`
</details>
</details>
## CLI 指南
<details>
<summary><b>展开 CLI 指南 — 常用命令与交互式斜杠命令</b></summary>
OpenOPC 同时提供高层的自然语言命令与更底层的 UI/服务命令。
概念上 OpenOPC 有两种执行模式:`task``company`。部分底层 CLI/服务命令仍将 `org` 作为「Company 模式 + 已保存组织架构」的兼容选择器;在 UI 中这表现为 Company 加一个架构选择。
### 常用命令
```bash
# 聊天
opc chat
opc chat -p demo --mode task --agent native "Inspect the failing tests"
opc chat -p demo --mode company --company-profile corporate "Ship this change with review"
# 可脚本化执行
opc exec -p demo --mode task --agent codex --stream-json "Run the migration check"
opc exec -p demo --mode company --company-profile corporate "Draft the research report"
# 项目生命周期
opc project list
opc project create demo
opc project switch demo
# 会话
opc session list -p demo
opc session create "New feature" -p demo --mode company
opc session send <task_id> "Continue with implementation" -p demo
opc session stop <task_id> -p demo
opc session continue <task_id> "Proceed after review" -p demo
# 运行时检查
opc runtime status -p demo
opc runtime logs <task_id> -p demo
opc work-item list -p demo
opc work-item show <work_item_id> -p demo
opc comms state <task_id> -p demo
# 招募
opc talent import /path/to/agency-agents
opc talent hire <template_id> <role_id> -p demo
```
### 交互式斜杠命令
运行 `opc chat`,然后使用斜杠命令:
```text
/status
/mode task
/mode company corporate
/agent codex
/project switch demo
/session list
/runtime --full
/logs <task_id> --full
/comms <task_id> --full
/org
/talent list
/market list
```
完整命令表见 [`docs/cli-chat-slash.md`](docs/cli-chat-slash.md)。
<details>
<summary><strong>CLI 命令分组</strong></summary>
| 分组 | 示例 |
|---|---|
| `opc project` | `list``show``create``switch``rename``delete --yes` |
| `opc session` | `list``create``show``config``send``rename``delete --yes``stop``continue``resume``complete` |
| `opc mode` | `show``set task``set company --profile corporate`、以及用于已保存组织公司运行的 `set org --org <id>` |
| `opc kanban` | `view``task create``task update``task move``task assign``task status``task delete --yes` |
| `opc agent` | `list``create``create-from-template``import-employee``detail``move``delete --yes` |
| `opc org` | `info``export``import``saved list/save/load/delete``role add/update/bulk-add/delete``policy update``strategy update``reset --yes` |
| `opc talent` | `list``employees``import``hire``scan``import-selected``employee-detail``import-agent` |
| `opc market` | `presets``browse``preview``apply-preset``export``install``list``uninstall --yes` |
| `opc runtime` | `status``checkpoints``logs``run` |
| `opc channels` | `status``login``start``stop` |
大多数服务类命令都支持 `--project/-p``--json`
对于已保存的组织架构,部分 CLI/服务命令目前将 `org` 作为兼容选择器使用,尽管概念上的运行时仍是 Company 模式:
```bash
opc exec -p demo --mode org --org hku_research_lab "Draft the research report"
opc session create "Research sprint" -p demo --mode org --org hku_research_lab
```
</details>
</details>
## 配置
在仓库根目录运行一次 `opc init`。它会创建 `.opc/`、从 `config/` 复制模板配置、创建记忆/技能/日志目录,并可选地创建第一个项目。
<details>
<summary><b>展开配置 — 配置文件、LLM 密钥、外部 Agent、频道、浏览器/MCP、故障排查</b></summary>
| 文件 | 用途 |
|---|---|
| `.opc/config/llm_config.yaml` | 默认模型、兼容 LiteLLM/OpenRouter 的 API base、API key、环境变量间接引用、路由、回退、temperature、token 限制。 |
| `.opc/config/system_config.yaml` | 运行时行为、浏览器工具、原生运行时、压缩、验证、权限、沙箱与安全设置。 |
| `.opc/config/agent_config.yaml` | 外部 Agent 命令路径、优先顺序、模型参数、会话模式、超时、审批模式与原生子 Agent 配置。 |
| `.opc/config/channel_config.yaml` | 外部消息提供方与凭据。入站发送者列表默认拒绝。 |
| `.opc/config/company_corporate_config.yaml` | 内置 Corporate 公司架构模板。 |
| `.opc/config/company_orgs/org_<id>_config.yaml` | Company 模式使用的自定义公司架构。 |
| `.opc/config/org_index.yaml` | 当前生效的已保存公司架构选择器。 |
### LLM 密钥
运行 `opc init` 后,编辑仓库本地 OPC home 中的 `.opc/config/llm_config.yaml`。如果设置了 `OPC_HOME`,则改为编辑 `$OPC_HOME/config/llm_config.yaml`
模板中的密钥留空。直接把 key 写入文件:
```yaml
llm:
default_model: "openai/gpt-5.4"
api_base: "https://openrouter.ai/api/v1"
api_key: "sk-or-v1-..." # 你的 OpenRouter(或其他提供方)API key
max_tokens: 32768 # 每次请求的最大输出 token;如果你的模型
# 输出上限更小,请调低
# context_window: 128000 # 总输入窗口。通常由 litellm 自动检测;
# 未收录的模型回退为 128000。仅当回退值
# 不适合你的模型时才取消注释并设置。
```
然后用 `opc status` 验证。
如果不想把密钥存在文件里,可以将 `api_key` 留空,并把 `api_key_env` 设置为持有密钥的环境变量名(例如 `api_key_env: "OPENROUTER_API_KEY"`)。
### 审批与 Agent 权限
`.opc/config/system_config.yaml``autonomy` 部分控制 Agent 无需询问即可执行多少操作。关键旋钮是 `max_auto_approve_risk` — 可被自动批准的最高风险等级:
```yaml
autonomy:
max_auto_approve_risk: medium # low | medium | high | critical
allow_native_tool_auto_approval: true
tool_first_use_approval: true # 每个工具首次使用时总是询问
```
每次原生工具调用在运行前都会做风险分级:已知的破坏性命令(`rm -rf``drop table`、force-push 等)与敏感关键词(凭据、部署等)为 `high`/`critical`,总是上报给人类;白名单中的安全前缀(`ls``git status` 等)为 `low`;其余为 `medium`,在自动批准前会经过 LLM 审查。
- `medium`(默认):平衡 — 普通命令无提示运行;危险命令上报。
- `low`:严格 — 不在安全白名单中的任何操作都需要审批。推荐用于共享或生产机器。
- `high`/`critical`:宽松 — 仅用于可随时丢弃的沙箱。
每个工具首次使用时总会提示(除非该工具在 `tool_approval_exemptions` 中),你的「始终允许」选择会累积到项目级白名单。
### 外部 Agent
Task 模式可以显式选择执行 Agent:
```bash
opc chat -p demo --mode task --agent codex "Implement the change"
```
可用值有 `native``codex``claude_code``cursor``opencode`。在 `.opc/config/agent_config.yaml` 中配置命令名、参数、超时、会话复用与审批行为。
在 Company 模式下,角色可以通过角色配置或 Org 角色检查器指定偏好的外部 Agent。角色的执行策略可以是 `auto``native``external`,并可选地指定偏好的外部 Agent。
### 飞书接入
```bash
pip install -e .[channels-feishu]
opc init
opc channels login feishu
```
编辑 `.opc/config/channel_config.yaml`
```yaml
channels:
feishu:
enabled: true
app_id: "cli_xxx"
app_secret: "..."
encrypt_key: ""
verification_token: ""
react_emoji: THUMBSUP
allow_from:
- "ou_xxx"
```
然后:
```bash
opc channels status
opc channels start -p demo
# 或运行常驻引擎 + 频道运行时:
opc run -p demo
```
飞书使用 `lark-oapi` WebSocket 客户端。`app_id``app_secret` 为必填;`encrypt_key``verification_token` 为可选,除非你的租户/应用配置要求。请保持 `allow_from` 显式配置;空列表会拒绝所有入站消息。
<details>
<summary><strong>其他频道提供方</strong></summary>
| 提供方 | 安装 extra | 运行方式 | 必填字段 |
|---|---|---|---|
| Telegram | `channels-telegram` | polling | `token` |
| Slack | `channels-slack` | socket | `bot_token``app_token` |
| Discord | `channels-discord` | socket | `token` |
| 钉钉 | `channels-dingtalk` | socket | `client_id``client_secret` |
| 邮件 | `channels-email` | polling | IMAP/SMTP 字段、`consent_granted` |
| Matrix | `channels-matrix` | sync/polling | `homeserver``access_token``user_id` |
| QQ | `channels-qq` | socket | `app_id``secret` |
| WhatsApp | `channels-whatsapp` | bridge | `bridge_url` |
| Mochat | `channels-mochat` | bridge | `base_url``claw_token``agent_user_id` |
常用命令:
```bash
opc channels login slack
opc channels status
opc channels start -p demo
opc channels stop
opc run -p demo
```
参见 [`docs/channels.md`](docs/channels.md) 与 [`docs/channel-bridges.md`](docs/channel-bridges.md)。
</details>
<details>
<summary><strong>浏览器工具与 MCP 服务器</strong></summary>
浏览器工具:
```bash
python -m playwright install chromium
```
`.opc/config/system_config.yaml` 中配置启动行为:
```yaml
system:
browser:
mode: embedded # embedded | chrome | auto
headless: true
chrome_channel: chrome
user_data_dir: ""
```
原生浏览器工具包括 `browser_navigate``browser_snapshot``browser_click``browser_type``browser_wait_for``browser_scroll``browser_select_option``browser_evaluate``browser_take_screenshot``browser_close`
MCP 服务器可添加到 `system_config.yaml``mcp_servers` 下。本地服务器使用 stdio 命令;远程服务器使用 HTTP/SSE 风格的 URL。发现的工具会以服务器前缀注册,避免命名冲突。
</details>
### 故障排查
<details>
<summary><strong>Office UI 无法打开或界面陈旧</strong></summary>
```bash
opc ui --rebuild
```
如果浏览器仍显示陈旧的 UI 状态,强制刷新页面。如果之前的进程在运行中途崩溃,先重启 `opc ui` 以释放内存中的锁。
</details>
<details>
<summary><strong>任务看起来卡住了</strong></summary>
先重启服务器并强制刷新浏览器。如果持久化的任务状态仍然异常,使用重置工具:
```bash
python scripts/reset_stuck_task.py --project <project> --session <session_id> --apply
python scripts/reset_stuck_task.py --all --apply
```
</details>
<details>
<summary><strong>外部 Agent 不可用</strong></summary>
运行:
```bash
opc status
```
检查 `.opc/config/agent_config.yaml` 中的命令名,例如 `codex``claude``cursor-agent``opencode`。禁用或调整你未安装的 Agent 的优先级。
</details>
<details>
<summary><strong>频道提供方收不到消息</strong></summary>
检查:
- 已安装对应的 extra,例如 `pip install -e .[channels-feishu]`
- 该提供方为 `enabled: true`
- 必填凭据已填写。
- `allow_from` 包含你期望的发送者 ID。
- `opc channels status` 显示该提供方已配置且可用。
</details>
</details>
## 生态与分享
OpenOPC 构建的一切都归你所有,可以保留、复用与分享 — 组织、员工、人才模板、技能与频道都只是文件。你可以导入一个流行的人才库、跨项目复用一个团队,或者把整个公司打包成可分享的 `.opcpkg`
```bash
# 从人才库(例如 agency-agents)招募到某个角色
opc talent import /path/to/agency-agents
opc talent hire <template_id> <role_id> -p demo
# 复用或分享整个组织
opc org export --json > my-org.yaml
opc market export --id hku_lab --name "HKU Lab" --output-dir packages
opc market install packages/hku_lab.opcpkg
```
## 路线图
OpenOPC 正在快速迭代。以下领域反映当前的开发重点 — 每一项都源自早期使用中发现的真实缺口。
| 领域 | 计划方向 |
|---|---|
| **角色级技能** | 角色配置已支持 `skill_refs`,Org UI 目前也展示技能元数据。下一步是让用户直接在 Org 页面选择哪些技能挂载到哪些角色 — 汇入更广泛的自演化技能生态。 |
| **秘书设置** | 秘书将成长为更强的配置与记忆管家:负责 OPC 系统记忆、分析与对比项目,并为 OpenOPC YAML 配置提供引导式设置。 |
| **Company 模式频道** | 外部频道将从简单的聊天入口演进为更丰富的 Company 模式工作流 — 支持角色感知的通知、结构化审批与跨平台协作。 |
| **CLI 对齐** | CLI 目前可用,但 Office UI 仍是更完整的界面。后续工作聚焦于从终端进行组织编辑、Company 模式检查、故障恢复与长时运行时控制。 |
| **TUI** | CLI 对齐成熟后将考虑完整的终端 UI。在此期间 Office UI 仍是主要界面。 |
| **市场与预设** | 更多架构预设、可招募的人才包、导入/导出工作流,以及用于分享与发现社区组件的包市场。 |
| **运行时打磨** | 持续改进恢复、检查点、执行进度可见性与可视化文档 — 让长时间的公司运行更可观察、更有韧性。 |
## 致谢
OpenOPC 的 Agent 设计、技能结构与人才模板生态受益于多个开源项目,在此致谢:
- [openai/codex](https://github.com/openai/codex/) 启发了实用的编码 Agent 工作流与执行模式。
- [BloopAI/vibe-kanban](https://github.com/BloopAI/vibe-kanban) 启发了以看板为中心的 Agent 工作管理与任务可见性。
- [msitarzewski/agency-agents](https://github.com/msitarzewski/agency-agents) 提供了人才模板的基础。本仓库包含的所有人才模板均导入自 `agency-agents`
- [HKUDS/nanobot](https://github.com/HKUDS/nanobot) 启发了面向技能的 Agent 设计与 `SKILL.md` 风格的组织方式。
- [pixel-agents-hq/pixel-agents](https://github.com/pixel-agents-hq/pixel-agents) 启发了以像素动画办公室可视化 Agent 活动的方式。
---
<p align="center">
<em> ❤️ 感谢访问 ✨ OpenOPC</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenOPC&style=for-the-badge&color=00d4ff"
alt="Views">
</p>
+2
View File
@@ -7,6 +7,8 @@ llm:
temperature: 1
max_tokens: 32768
# reasoning_effort: "high" # optional; use a provider-supported level
# such as low, medium, or high
routing: {}
fallback: {}
+1
View File
@@ -273,6 +273,7 @@ class LLMConfig(BaseModel):
fallback: dict[str, Any] = Field(default_factory=dict)
temperature: float = 0.3
max_tokens: int = 32768
reasoning_effort: str | None = None
# Total input context window (tokens) for the active model. Set this when
# the model is not mapped in litellm (e.g. proxy/self-hosted models like
# doubao/minimax/glm), so the context-usage ring and compaction thresholds
+124 -8
View File
@@ -37,6 +37,7 @@ from opc.core.config import (
company_org_path,
get_opc_home,
get_project_workplace,
validate_organization_id,
)
from opc.core.events import EventBus
from opc.core.models import (
@@ -3563,6 +3564,39 @@ class OPCEngine:
return False
return bool(dict(getattr(task, "metadata", {}) or {}).get("shared_role_session", False))
@staticmethod
def _normalize_durable_org_id(value: Any) -> str:
try:
return validate_organization_id(value)
except ValueError:
return ""
@staticmethod
def _runtime_org_id_for_identity(
decision: RouterDecision | None,
metadata: dict[str, Any] | None,
org_config: Any | None,
) -> str | None:
"""Return the durable custom-org ID for a company runtime task."""
task_metadata = dict(metadata or {})
profile = str(
getattr(decision, "company_profile", "")
or task_metadata.get("company_profile", "")
or getattr(org_config, "company_profile", "")
or ""
).strip().lower()
if profile != "custom":
return None
for candidate in (
getattr(decision, "org_id", None),
task_metadata.get("org_id"),
task_metadata.get("organization_id"),
):
normalized = str(candidate or "").strip()
if normalized:
return normalized
return None
@staticmethod
def _shared_company_role_session_id(
parent_session_id: str,
@@ -3601,6 +3635,17 @@ class OPCEngine:
root_session: bool = False,
) -> Task:
assert self.store and self.memory
runtime_company_profile = str(
getattr(decision, "company_profile", "")
or (work_item.metadata or {}).get("company_profile", "")
or getattr(getattr(self.config, "org", None), "company_profile", "")
or ""
).strip().lower()
runtime_org_id = self._runtime_org_id_for_identity(
decision,
getattr(work_item, "metadata", None),
getattr(getattr(self, "config", None), "org", None),
)
role_id = str(work_item.role_id or "").strip()
seat_id = str((work_item.metadata or {}).get("seat_id", "") or "").strip()
team_id = str((work_item.metadata or {}).get("team_id", "") or work_item.cell_id or "").strip()
@@ -3653,6 +3698,42 @@ class OPCEngine:
set_linked_work_item_id(existing, work_item.work_item_id)
existing.session_id = session_id
existing.metadata = dict(existing.metadata or {})
if runtime_company_profile == "custom":
persisted_org_id = self._normalize_durable_org_id(getattr(existing, "org_id", None))
incoming_org_id = self._normalize_durable_org_id(runtime_org_id)
if persisted_org_id and incoming_org_id and persisted_org_id != incoming_org_id:
from opc.plugins.office_ui.services.models import ServiceError
raise ServiceError(
"org_id_conflict",
"org_id_conflict",
{
"project_id": self.project_id or "default",
"task_id": str(getattr(work_item, "work_item_id", "") or ""),
"persisted_org_id": persisted_org_id,
"incoming_org_id": incoming_org_id,
"reason": "custom_company_run_org_id_conflict",
},
)
resolved_org_id = persisted_org_id or incoming_org_id
if not resolved_org_id:
from opc.plugins.office_ui.services.models import ServiceError
raise ServiceError(
"org_id_required",
"org_id_required",
{
"project_id": self.project_id or "default",
"task_id": str(getattr(work_item, "work_item_id", "") or ""),
"reason": "custom_company_run_requires_durable_org_id",
},
)
runtime_org_id = resolved_org_id
existing.org_id = resolved_org_id
existing.metadata["org_id"] = resolved_org_id
existing.metadata["organization_id"] = resolved_org_id
elif runtime_org_id:
existing.org_id = runtime_org_id
existing.metadata["org_id"] = runtime_org_id
existing.metadata["organization_id"] = runtime_org_id
existing.metadata["shared_role_session"] = True
existing.metadata["shared_role_id"] = role_id
existing.metadata["company_runtime_root_session_id"] = parent_session_id
@@ -3694,6 +3775,17 @@ class OPCEngine:
)
await self.store.save_task(existing)
return existing
if runtime_company_profile == "custom" and not runtime_org_id:
from opc.plugins.office_ui.services.models import ServiceError
raise ServiceError(
"org_id_required",
"org_id_required",
{
"project_id": self.project_id or "default",
"task_id": str(getattr(work_item, "work_item_id", "") or ""),
"reason": "custom_company_run_requires_durable_org_id",
},
)
employee_assignment = dict(topology_seat.get("employee_assignment", {}) or {})
if not employee_assignment and self.org_engine and role_id:
preferred_employee_id = str(topology_seat.get("employee_id", "") or "").strip() or None
@@ -3744,6 +3836,18 @@ class OPCEngine:
owner_execution_copy = build_work_item_owner_execution_copy(work_item)
owner_execution_copy.setdefault("delegation_role_session_id", role_session_id)
owner_execution_copy["work_kind"] = work_item_turn_type
runtime_identity_metadata = (
{
"org_id": runtime_org_id or "",
"organization_id": runtime_org_id or "",
}
if runtime_company_profile == "custom"
else {
"organization_id": str(
getattr(getattr(self.config, "org", None), "organization_id", "") or ""
).strip(),
}
)
task = Task(
title=str(work_item.title or work_item_projection_ref or "Runtime Work Item").strip(),
description=(
@@ -3757,6 +3861,7 @@ class OPCEngine:
session_id=session_id,
parent_session_id=parent_session_id,
assigned_external_agent=assigned_external_agent,
org_id=runtime_org_id,
metadata=mark_work_item_projection(mark_work_item_runtime({
"mode": "company",
"execution_mode": decision.mode.value,
@@ -3765,7 +3870,6 @@ class OPCEngine:
"original_message": original_message,
"router_preferred_agent": decision.preferred_agent,
"company_profile": decision.company_profile or getattr(self.config.org, "company_profile", "corporate"),
"organization_id": getattr(self.config.org, "organization_id", ""),
"organization_name": getattr(self.config.org, "organization_name", ""),
"organization_config_file": getattr(self.config.org, "organization_config_file", ""),
"delegation_playbook": dict(delegation_playbook),
@@ -3780,6 +3884,7 @@ class OPCEngine:
),
"runtime_topology": copy.deepcopy(runtime_topology),
**owner_execution_copy,
**runtime_identity_metadata,
"work_item_projection_ref": work_item_projection_ref,
"seat_manager_role_id": str(topology_seat.get("manager_role_id", "") or "").strip(),
"manager_role_id": str(topology_seat.get("manager_role_id", "") or "").strip(),
@@ -12649,7 +12754,12 @@ class OPCEngine:
seen_employee_ids.add(employee_id)
history = ""
if self.memory:
organization_id = str(getattr(getattr(self.config, "org", None), "organization_id", "") or "").strip()
organization_id = str(
getattr(delivery_task, "org_id", "")
or (delivery_task.metadata or {}).get("org_id")
or (delivery_task.metadata or {}).get("organization_id")
or ""
).strip()
history = self.memory.employee_evolution.build_employee_delta_context(
employee_id,
project_id=task.project_id,
@@ -13191,13 +13301,19 @@ class OPCEngine:
await self._mark_company_runtime_checkpoint_status(checkpoint, status="invalid")
return "Could not run self-evolution because the runtime task set could not be restored."
from opc.plugins.office_ui.execution_identity import resolve_delivery_task_org_identity
organization_id, identity_error = resolve_delivery_task_org_identity(
waiting_task,
payload=payload,
active_org_id=getattr(getattr(self.config, "org", None), "organization_id", ""),
default_org_id=DEFAULT_ORGANIZATION_ID,
)
if identity_error:
await self._mark_company_runtime_checkpoint_status(checkpoint, status="invalid")
return f"Could not run self-evolution because {identity_error}."
plan = deserialize_company_work_item_runtime_plan(payload.get("company_work_item_plan") or payload.get("plan", {}))
organization_id = str(
getattr(waiting_task, "org_id", "")
or payload.get("organization_id")
or getattr(getattr(self.config, "org", None), "organization_id", "")
or DEFAULT_ORGANIZATION_ID
).strip() or DEFAULT_ORGANIZATION_ID
root_role_id = str(
getattr(plan, "final_decider_role_id", "")
or plan.metadata.get("final_decider_role_id", "")
+72 -4
View File
@@ -22,7 +22,11 @@ from opc.core.active_task_runs import (
ActiveTaskRunAdmissionClosed,
ActiveTaskRunRegistry,
)
from opc.core.config import DEFAULT_EXTERNAL_AGENT_STARTUP_TIMEOUT_SECONDS, DEFAULT_ORGANIZATION_ID
from opc.core.config import (
DEFAULT_EXTERNAL_AGENT_STARTUP_TIMEOUT_SECONDS,
DEFAULT_ORGANIZATION_ID,
validate_organization_id,
)
from opc.core.models import (
AdaptiveRoleProfile,
AdaptiveSignalSpec,
@@ -1326,6 +1330,26 @@ class CompanyRuntimeSpecBuilder(CompanyRuntimeWorkItemHelper):
or "corporate"
).strip() or "corporate"
org_config = getattr(self.org_engine.config, "org", None)
selected_org_id = ""
if profile == "custom":
try:
selected_org_id = validate_organization_id(getattr(decision, "org_id", None))
except ValueError:
selected_org_id = ""
if not selected_org_id:
# A custom-organization run must carry a durable org_id on the
# decision. Never derive it from the process-wide active
# config; fail closed before any work items are created.
from opc.plugins.office_ui.services.models import ServiceError
raise ServiceError(
"org_id_required",
"org_id_required",
{
"company_profile": profile,
"reason": "custom_company_run_requires_durable_org_id",
},
)
decision.org_id = selected_org_id
metadata: dict[str, Any] = {
"source": "work_item_runtime",
"execution_mode": "company_mode",
@@ -1333,7 +1357,11 @@ class CompanyRuntimeSpecBuilder(CompanyRuntimeWorkItemHelper):
"runtime_model": "multi_team_org",
"work_item_driven": True,
"company_profile": profile,
"organization_id": str(getattr(org_config, "organization_id", "") or "").strip(),
"organization_id": (
selected_org_id
if profile == "custom"
else str(getattr(org_config, "organization_id", "") or "").strip()
),
"organization_name": str(getattr(org_config, "organization_name", "") or "").strip(),
"organization_config_file": str(getattr(org_config, "organization_config_file", "") or "").strip(),
"original_request": original_message,
@@ -1341,7 +1369,7 @@ class CompanyRuntimeSpecBuilder(CompanyRuntimeWorkItemHelper):
"domains": list(getattr(decision, "domains", []) or []),
"preferred_agent": getattr(decision, "preferred_agent", None),
"requested_sub_tasks": list(getattr(decision, "sub_tasks", []) or []),
"org_id": getattr(decision, "org_id", None),
"org_id": selected_org_id if profile == "custom" else getattr(decision, "org_id", None),
}
return CompanyRuntimeSpec(
profile=profile,
@@ -4247,7 +4275,21 @@ class CompanyWorkItemExecutor:
existing_task_ids = {str(task.id or "").strip() for task in existing_tasks if str(task.id or "").strip()}
existing_work_item_ids = set(task_by_linked_work_item_id(existing_tasks))
root_task = sorted(existing_tasks, key=lambda item: (item.created_at, item.id))[0]
runtime_topology = dict((root_task.metadata or {}).get("runtime_topology", {}) or {})
root_metadata = dict(root_task.metadata or {})
custom_runtime = str(root_metadata.get("company_profile", "") or "").strip().lower() == "custom"
runtime_org_id = str(
getattr(root_task, "org_id", "")
or root_metadata.get("org_id")
or root_metadata.get("organization_id")
or ""
).strip() or None
if not custom_runtime:
runtime_org_id = None
if runtime_org_id:
for existing_task in existing_tasks:
if self._sync_runtime_org_identity(existing_task, runtime_org_id):
await self.store.save_task(existing_task)
runtime_topology = dict(root_metadata.get("runtime_topology", {}) or {})
root_parent_session_id = str(
root_task.parent_session_id
or root_task.session_id
@@ -4283,6 +4325,8 @@ class CompanyWorkItemExecutor:
persisted = await get_runtime_task(work_item_id)
if persisted is not None:
set_linked_work_item_id(persisted, work_item_id)
if self._sync_runtime_org_identity(persisted, runtime_org_id):
await self.store.save_task(persisted)
self._raise_for_runtime_projection_issues(persisted, work_item, work_item_by_id)
if persisted.id not in existing_task_ids:
existing_tasks.append(persisted)
@@ -4413,6 +4457,9 @@ class CompanyWorkItemExecutor:
task_metadata.update(copy_work_item_execution_metadata(work_item))
task_metadata.update(owner_execution_copy)
task_metadata[WORK_ITEM_TURN_TYPE_KEY] = turn_type
if custom_runtime:
task_metadata["org_id"] = runtime_org_id or ""
task_metadata["organization_id"] = runtime_org_id or ""
temp_task = Task(
id=str(uuid.uuid4()),
title=str(getattr(work_item, "title", "") or projection_id or "Runtime Work Item").strip(),
@@ -4423,6 +4470,7 @@ class CompanyWorkItemExecutor:
session_id=session_id,
parent_session_id=root_parent_session_id,
assigned_external_agent=assigned_external_agent,
org_id=runtime_org_id,
metadata=task_metadata,
)
dependency_projection_ids: list[str] = []
@@ -4454,6 +4502,7 @@ class CompanyWorkItemExecutor:
parent_session_id=temp_task.parent_session_id,
assigned_external_agent=temp_task.assigned_external_agent,
dependencies=dependency_projection_ids,
org_id=runtime_org_id,
metadata=task_metadata,
)
set_linked_work_item_id(task, work_item_id)
@@ -4471,6 +4520,8 @@ class CompanyWorkItemExecutor:
"failed to link new runtime Task "
f"{task.id} for WorkItem {work_item_id}"
)
if self._sync_runtime_org_identity(task, runtime_org_id):
await self.store.save_task(task)
set_linked_work_item_id(task, work_item_id)
self._raise_for_runtime_projection_issues(task, work_item, work_item_by_id)
if self.memory is not None and task.session_id:
@@ -4510,6 +4561,23 @@ class CompanyWorkItemExecutor:
await self.save_task(task)
return existing_tasks
@staticmethod
def _sync_runtime_org_identity(task: Task, org_id: str | None) -> bool:
normalized_org_id = str(org_id or "").strip()
if not normalized_org_id:
return False
metadata = dict(task.metadata or {})
changed = str(getattr(task, "org_id", "") or "").strip() != normalized_org_id
changed = changed or metadata.get("org_id") != normalized_org_id
changed = changed or metadata.get("organization_id") != normalized_org_id
if not changed:
return False
task.org_id = normalized_org_id
metadata["org_id"] = normalized_org_id
metadata["organization_id"] = normalized_org_id
task.metadata = metadata
return True
@staticmethod
def _runtime_work_kind_to_work_item_turn_type(work_kind: str) -> str:
return canonical_work_item_turn_type_for_kind(work_kind)
+15 -1
View File
@@ -64,8 +64,22 @@ class CustomRuntimeRunner:
) -> str:
from opc.engine import OPCEngine
from opc.layer2_organization.phase_hooks import unregister_dispatcher_wake
from opc.plugins.office_ui.services.models import ServiceError
org_config, resolved_org_id = self._build_org_config(org_id)
normalized_org_id = str(org_id or "").strip()
if not normalized_org_id:
# Isolated org mode must carry a durable org_id; resolving the
# active index here would silently route the run to whichever
# organization is currently loaded.
raise ServiceError(
"org_id_required",
"org_id_required",
{
"project_id": project_id or self.parent.project_id or "default",
"reason": "custom_company_run_requires_durable_org_id",
},
)
org_config, resolved_org_id = self._build_org_config(normalized_org_id)
normalized_project_id = str(project_id or self.parent.project_id or "default").strip() or "default"
shared_store = getattr(self.parent, "store", None)
runtime = OPCEngine(
+4
View File
@@ -569,6 +569,8 @@ class LLMProvider:
"max_tokens": max_tok,
**kwargs,
}
if self.config.reasoning_effort and "reasoning_effort" not in call_kwargs:
call_kwargs["reasoning_effort"] = self.config.reasoning_effort
if self._api_base:
call_kwargs["api_base"] = self._api_base
if self._api_key:
@@ -715,6 +717,8 @@ class LLMProvider:
"stream": True,
**kwargs,
}
if self.config.reasoning_effort and "reasoning_effort" not in call_kwargs:
call_kwargs["reasoning_effort"] = self.config.reasoning_effort
if self._api_base:
call_kwargs["api_base"] = self._api_base
if self._api_key:
+57 -3
View File
@@ -14,9 +14,10 @@ for that identity:
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Mapping
from opc.core.config import validate_organization_id
from opc.layer2_organization.company_runtime_identity import is_company_runtime_task
PREFERRED_AGENTS: frozenset[str] = frozenset({
"native",
@@ -154,11 +155,12 @@ def execution_identity_from_task(
company_profile = metadata.get("company_profile")
metadata_profile = str(company_profile or "").strip().lower()
metadata_org_id = (
metadata.get("org_id")
getattr(task, "org_id", None)
or metadata.get("org_id")
or metadata.get("organization_id")
or getattr(task, "org_id", None)
or ""
)
mode_hint = str(metadata.get("mode", "") or "").strip().lower()
if raw_exec_mode:
exec_mode = raw_exec_mode
@@ -169,6 +171,15 @@ def execution_identity_from_task(
elif execution_mode == "company_mode" or metadata_profile:
exec_mode = "company"
explicit = True
elif (
mode_hint in {"company", "org", "custom"}
or is_company_runtime_task(task)
):
# Older company/runtime rows may only retain a mode marker or the
# runtime marker itself. Treat those rows as explicit company
# identity so they cannot fall through to task-mode/global defaults.
exec_mode = "org" if metadata_org_id else "company"
explicit = True
elif metadata_org_id:
exec_mode = "org"
explicit = True
@@ -187,3 +198,46 @@ def execution_identity_from_task(
default_preferred_agent=default_preferred_agent,
explicit_exec_mode=explicit,
)
def resolve_delivery_task_org_identity(
task: Any | None,
*,
payload: Mapping[str, Any] | None = None,
active_org_id: Any = "",
default_org_id: Any = "",
) -> tuple[str, str]:
"""Validate the org identity of a delivery self-evolution task.
Returns ``(organization_id, error)`` with at most one non-empty. Prefers
``Task.org_id``, then task metadata org fields; conflicting sources are
rejected. Checkpoint-payload org fields are a last-resort legacy fallback
and never override task/metadata identity. The active configuration org
is only consulted for a confirmed corporate task; custom-org deliveries
without a durable org id fail closed.
"""
metadata = task_metadata(task)
candidates: list[str] = []
for value in (
getattr(task, "org_id", None),
metadata.get("org_id"),
metadata.get("organization_id"),
):
normalized = normalize_org_id(value)
if normalized and normalized not in candidates:
candidates.append(normalized)
if len(candidates) > 1:
return "", "the delivery task org identity conflicts across task and metadata sources"
task_org_id = candidates[0] if candidates else ""
if task_org_id:
return task_org_id, ""
payload_org_id = normalize_org_id(
(payload or {}).get("org_id")
or (payload or {}).get("organization_id")
)
if payload_org_id:
return payload_org_id, ""
identity = execution_identity_from_task(task)
if identity.is_company:
return normalize_org_id(active_org_id) or normalize_org_id(default_org_id), ""
return "", "the custom-organization delivery task has no durable org identity"
+14 -14
View File
@@ -1,16 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-Dw3OK9za.js"></script>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-Cdi_JV6w.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-B6-ikSHW.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -87,13 +87,14 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
background: 'rgba(0,0,0,0.5)',
}}>
<div style={{
background: 'var(--bg-surface, #1e1e2e)', borderRadius: 12, padding: '24px 32px',
background: 'var(--bg-elevated)', borderRadius: 12, padding: '24px 32px',
border: '1px solid var(--border)', color: 'var(--text)',
maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
}}>
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15, color: 'var(--text)' }}>
{t('project.confirmTitle', { name: confirmDelete })}
</p>
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-secondary)' }}>
{t('project.confirmBody')}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
+6 -19
View File
@@ -618,25 +618,6 @@ class SessionService:
default_preferred_agent=self.context.mode_state.task_preferred_agent,
explicit_exec_mode=True,
)
if identity.is_custom_org and not identity.org_id:
# Role-task rows may lack org_id; mirror create()'s active-org fallback
fallback_org_id = ""
if self.context.get_active_saved_org_name is not None:
try:
fallback_org_id = await self.context.get_active_saved_org_name()
except Exception:
logger.opt(exception=True).debug(
"persist_session_config: failed to resolve active saved org"
)
if fallback_org_id:
identity = canonicalize_execution_identity(
exec_mode=exec_mode,
company_profile=company_profile,
preferred_agent=preferred_agent,
org_id=fallback_org_id,
default_preferred_agent=self.context.mode_state.task_preferred_agent,
explicit_exec_mode=True,
)
if identity.is_custom_org and not identity.org_id:
raise ServiceError("org_id_required", "org_id_required", {
"task_id": str(getattr(task, "id", "") or ""),
@@ -1182,6 +1163,12 @@ class SessionService:
else:
checkpoint = None
org_id = self.resolve_task_org_id(config_task) if engine_mode == "org" else ""
if engine_mode == "org" and not org_id:
raise ServiceError(
"org_id_required",
"org_id_required",
{"project_id": project_id, "task_id": resolved_task_id},
)
message_metadata: dict[str, Any] = {"ui_force_resume": True}
if checkpoint is not None:
message_metadata.update({
+159 -30
View File
@@ -4978,16 +4978,38 @@ class WSHandler:
if task is None:
return None
exec_mode, _ = self._resolve_task_session_config(task)
if not self._is_company_session_exec_mode(exec_mode):
runtime_bound = is_company_runtime_task(task)
if not runtime_bound and not self._is_company_session_exec_mode(exec_mode):
return task
try:
target = await self._resolve_company_runtime_target(task_id, engine=engine)
except Exception:
except ServiceError:
if runtime_bound:
raise
logger.opt(exception=True).debug(
"failed to resolve durable session config task"
)
return task
return (target or {}).get("config_task") or task
except Exception as exc:
if runtime_bound:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity could not be resolved",
{"task_id": str(task_id or "").strip()},
) from exc
logger.opt(exception=True).debug(
"failed to resolve durable session config task"
)
return task
if target is not None:
return target.get("config_task") or task
if runtime_bound:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity could not be resolved",
{"task_id": str(task_id or "").strip()},
)
return task
@staticmethod
def _is_company_session_exec_mode(exec_mode: Any) -> bool:
@@ -5059,14 +5081,24 @@ class WSHandler:
# Look up session_id from task
session_id: str | None = None
task = None
config_task = None
preferred_agent = self._task_preferred_agent
session_org_id = self._normalize_session_org_id(org_id)
if task_id and getattr(engine, "store", None):
task = await engine.store.get_task(task_id)
if task:
session_id = task.session_id
identity = self._resolve_task_identity(
company_runtime_target: dict[str, Any] | None = None
try:
if task is not None:
config_task = await self._resolve_session_runtime_config_task(
task_id,
task,
engine=engine,
)
identity = self._resolve_task_identity(
config_task,
default_exec_mode=mode,
default_company_profile=profile,
default_preferred_agent=preferred_agent,
@@ -5076,9 +5108,12 @@ class WSHandler:
profile = identity.company_profile
session_org_id = identity.org_id
preferred_agent = identity.preferred_agent
company_runtime_target: dict[str, Any] | None = None
try:
if identity.is_custom_org and not identity.org_id:
raise ServiceError(
"org_id_required",
"org_id_required",
{"project_id": pid, "task_id": task_id},
)
content = f"{title}\n{description}".strip()
engine_mode, company_profile = self._resolve_engine_mode(mode, profile)
engine_preferred_agent = preferred_agent if engine_mode == "project" else None
@@ -7819,6 +7854,16 @@ class WSHandler:
parent_task = await run_engine.store.get_task(parent_task_id)
except Exception:
logger.opt(exception=True).debug("failed to load parent task for delivery feedback reply")
if parent_task is None:
raise ServiceError(
"org_id_required",
"org_id_required",
{
"project_id": pid,
"task_id": parent_task_id,
"reason": "delivery_feedback_requires_durable_parent_task",
},
)
session_exec_mode = self._normalize_session_exec_mode(self._exec_mode)
session_company_profile = self._normalize_session_company_profile(self._company_profile)
session_org_id = ""
@@ -8124,17 +8169,6 @@ class WSHandler:
pid = self._normalize_project_id(run_project_id or getattr(run_engine, "project_id", None))
async with lock:
try:
try:
await self._set_company_runtime_control(
target,
state="resuming",
checkpoint_id=str(
getattr(checkpoint, "checkpoint_id", "") or ""
).strip(),
)
except Exception:
logger.opt(exception=True).debug("failed to broadcast company suspend reply routing state")
config_task = target.get("config_task")
session_exec_mode = self._normalize_session_exec_mode(self._exec_mode)
session_company_profile = self._normalize_session_company_profile(self._company_profile)
@@ -8146,6 +8180,27 @@ class WSHandler:
session_exec_mode,
session_company_profile,
)
if engine_mode == "org" and not session_org_id:
raise ServiceError(
"org_id_required",
"org_id_required",
{
"project_id": pid,
"task_id": str(target.get("config_source_task_id", "") or "").strip(),
},
)
try:
await self._set_company_runtime_control(
target,
state="resuming",
checkpoint_id=str(
getattr(checkpoint, "checkpoint_id", "") or ""
).strip(),
)
except Exception:
logger.opt(exception=True).debug("failed to broadcast company suspend reply routing state")
engine_message_metadata = dict(message_metadata or {})
engine_message_metadata.update({
"response_to_checkpoint_id": str(getattr(checkpoint, "checkpoint_id", "") or ""),
@@ -8612,13 +8667,43 @@ class WSHandler:
item
for item in pending or []
if str(getattr(item, "checkpoint_id", "") or "").strip() == checkpoint_id
and str(getattr(item, "checkpoint_type", "") or "").strip()
in self._LOCK_FREE_CHECKPOINT_ANSWER_TYPES
and str(getattr(item, "checkpoint_type", "") or "").strip() == checkpoint_type
),
None,
)
if checkpoint is None:
return False
# Ownership scoping must accept every channel the card legitimately
# reaches, not just the task that raised the checkpoint: company gate
# cards raised by role work items are answered from the run's anchor
# chat, whose task id only appears in payload["task_ids"] (the same
# linkage set _find_parked_checkpoint_for_deferred_resume uses). An
# exact task/session equality check would silently disable this fast
# path for those answers and re-open the late-approval lock wedge.
checkpoint_payload = dict(getattr(checkpoint, "payload", {}) or {})
requester_task_id = str(task_id or "").strip()
requester_session_id = str(session_id or "").strip()
linked_task_ids = {
str(checkpoint_payload.get("task_id") or "").strip(),
str(checkpoint_payload.get("waiting_task_id") or "").strip(),
str(getattr(checkpoint, "task_id", "") or "").strip(),
}
linked_task_ids.update(
str(item or "").strip()
for item in list(checkpoint_payload.get("task_ids", []) or [])
)
linked_task_ids.discard("")
linked_session_ids = {
str(getattr(checkpoint, "session_id", "") or "").strip(),
str(checkpoint_payload.get("session_id") or "").strip(),
}
linked_session_ids.discard("")
has_linkage = bool(linked_task_ids or linked_session_ids)
if has_linkage and (
requester_task_id not in linked_task_ids
and (not requester_session_id or requester_session_id not in linked_session_ids)
):
return False
logger.info(
f"Lock-free checkpoint answer: task lock for {task_id} is held by a "
f"live turn; delivering reply to pending checkpoint {checkpoint_id} "
@@ -8722,16 +8807,50 @@ class WSHandler:
from opc.core.models import TaskStatus
task = await store.get_task(task_id)
if task:
config_task = await self._resolve_session_runtime_config_task(
task_id,
task,
engine=engine,
)
session_exec_mode, session_company_profile = self._resolve_task_session_config(
config_task
)
session_org_id = self._resolve_task_org_id(config_task)
session_preferred_agent = self._resolve_task_preferred_agent(config_task)
# This coroutine usually runs as a fire-and-forget background
# task (_track_session), where a raised ServiceError is only
# logged and the user's message silently vanishes. Fail closed
# with a visible chat error instead of raising.
try:
config_task = await self._resolve_session_runtime_config_task(
task_id,
task,
engine=engine,
)
session_exec_mode, session_company_profile = self._resolve_task_session_config(
config_task
)
session_org_id = self._resolve_task_org_id(config_task)
session_preferred_agent = self._resolve_task_preferred_agent(config_task)
if (
session_exec_mode in {"org", "custom"}
and not session_org_id
and is_company_runtime_task(task)
):
raise ServiceError(
"org_id_required",
"org_id_required",
{"project_id": pid, "task_id": task_id},
)
except ServiceError as exc:
logger.warning(
f"Session message for task {task_id} rejected during "
f"runtime identity resolution: {exc.code}"
)
try:
msg = await self.chat_store.insert_message(
channel_id=channel_id,
sender="system",
sender_name="OPC",
content=f"Error: {exc.message}",
project_id=pid,
)
await self.broadcast({"type": "session_message", "payload": msg})
except Exception:
logger.opt(exception=True).debug(
"failed to surface session identity error"
)
return
if await self._try_lock_free_parked_checkpoint_answer(
task_id=task_id,
@@ -8769,6 +8888,16 @@ class WSHandler:
)
session_org_id = self._resolve_task_org_id(config_task)
session_preferred_agent = self._resolve_task_preferred_agent(config_task)
if (
session_exec_mode in {"org", "custom"}
and not session_org_id
and is_company_runtime_task(task)
):
raise ServiceError(
"org_id_required",
"org_id_required",
{"project_id": pid, "task_id": task_id},
)
if task.status == TaskStatus.DONE and self._is_company_session_exec_mode(session_exec_mode):
task.status = TaskStatus.IDLE
task.metadata = dict(getattr(task, "metadata", {}) or {})
+4
View File
@@ -2072,6 +2072,7 @@ class CompanyRecruiterFlowTests(unittest.IsolatedAsyncioTestCase):
decision = RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id="test-org",
domains=[],
)
runtime_spec = engine.company_runtime_spec_builder.build_spec(
@@ -2140,6 +2141,7 @@ class CompanyRecruiterFlowTests(unittest.IsolatedAsyncioTestCase):
decision = RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id="test-org",
domains=[],
)
runtime_spec = engine.company_runtime_spec_builder.build_spec(
@@ -2199,6 +2201,7 @@ class CompanyRecruiterFlowTests(unittest.IsolatedAsyncioTestCase):
decision = RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id="test-org",
domains=[],
)
runtime_spec = engine.company_runtime_spec_builder.build_spec(
@@ -2312,6 +2315,7 @@ class CompanyRecruiterFlowTests(unittest.IsolatedAsyncioTestCase):
decision = RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id="test-org",
domains=[],
)
runtime_spec = engine.company_runtime_spec_builder.build_spec(
+206
View File
@@ -260,6 +260,212 @@ def test_work_item_chat_resume_uses_canonical_ui_anchor_as_engine_origin() -> No
asyncio.run(scenario())
def test_company_suspend_reply_routes_selected_org_to_engine() -> None:
async def scenario() -> None:
_tasks, checkpoint = _runtime_records()
handler = WSHandler.__new__(WSHandler)
handler._exec_mode = "task"
handler._company_profile = "corporate"
handler._shutting_down = False
handler._active_runtime_children = {}
handler._session_to_task = {}
handler._task_bg_context = {}
handler._company_suspend_reply_locks = {"runtime-session": asyncio.Lock()}
handler.chat_store = None
handler._set_company_runtime_control = AsyncMock()
handler._normalize_session_exec_mode = MagicMock(return_value="task")
handler._normalize_session_company_profile = MagicMock(return_value="corporate")
handler._resolve_task_session_config = MagicMock(return_value=("org", "custom"))
handler._resolve_task_org_id = MagicMock(return_value="selected-org")
handler._extract_checkpoint_metadata = AsyncMock(return_value=None)
handler._sync_task_transcript_messages = AsyncMock()
handler.on_kanban_changed = AsyncMock()
handler._flush_progress = AsyncMock()
run_engine = SimpleNamespace(
project_id="project-a",
process_message=AsyncMock(return_value="resumed"),
)
target = {
"ui_anchor_task_id": "ui-anchor",
"config_task": SimpleNamespace(
metadata={"exec_mode": "org", "company_profile": "custom"},
org_id="selected-org",
),
}
await handler._process_company_suspend_reply(
ui_task_id="final-decider",
runtime_session_id="runtime-session",
content="continue",
attachment_refs=None,
message_metadata={"ui_force_resume": True},
user_message_id=None,
user_message_created_at=None,
run_engine=run_engine,
run_project_id="project-a",
target=target,
checkpoint=checkpoint,
lock=handler._company_suspend_reply_locks["runtime-session"],
)
call = run_engine.process_message.await_args
assert call.kwargs["org_id"] == "selected-org"
asyncio.run(scenario())
def test_delivery_feedback_reply_fails_closed_without_durable_parent_task() -> None:
async def scenario() -> None:
handler = WSHandler.__new__(WSHandler)
handler._exec_mode = "task"
handler._company_profile = "corporate"
handler._shutting_down = False
handler._active_runtime_children = {}
handler._session_to_task = {}
handler._task_bg_context = {}
handler._company_delivery_feedback_reply_locks = {}
handler.chat_store = None
handler._store_is_ready = MagicMock(return_value=True)
handler._normalize_session_exec_mode = MagicMock(return_value="task")
handler._normalize_session_company_profile = MagicMock(return_value="corporate")
handler._resolve_task_session_config = MagicMock(return_value=("org", "custom"))
handler._resolve_task_org_id = MagicMock(return_value="")
handler._chat_store_is_ready = MagicMock(return_value=False)
handler._flush_progress = AsyncMock()
run_engine = SimpleNamespace(
project_id="project-a",
store=SimpleNamespace(get_task=AsyncMock(return_value=None)),
run_company_delivery_self_evolution_checkpoint=AsyncMock(return_value="ran"),
)
await handler._process_company_delivery_feedback_reply(
parent_task_id="delivery-task",
parent_session_id="delivery-session",
reply_channel_id="session:delivery-task",
content="approved",
attachment_refs=None,
message_metadata=None,
user_message_id=None,
user_message_created_at=None,
run_engine=run_engine,
run_project_id="project-a",
checkpoint=SimpleNamespace(checkpoint_id="cp-delivery", payload={}),
waiting_task_id="delivery-task",
lock=asyncio.Lock(),
)
run_engine.run_company_delivery_self_evolution_checkpoint.assert_not_awaited()
handler._resolve_task_org_id.assert_not_called()
asyncio.run(scenario())
def test_delivery_feedback_reply_proceeds_with_durable_parent_task() -> None:
async def scenario() -> None:
handler = WSHandler.__new__(WSHandler)
handler._exec_mode = "task"
handler._company_profile = "corporate"
handler._shutting_down = False
handler._active_runtime_children = {}
handler._session_to_task = {}
handler._task_bg_context = {}
handler._company_delivery_feedback_reply_locks = {}
handler.chat_store = None
handler._store_is_ready = MagicMock(return_value=True)
handler._normalize_session_exec_mode = MagicMock(return_value="task")
handler._normalize_session_company_profile = MagicMock(return_value="corporate")
handler._resolve_task_session_config = MagicMock(return_value=("org", "custom"))
handler._resolve_task_org_id = MagicMock(return_value="selected-org")
handler._chat_store_is_ready = MagicMock(return_value=False)
handler._flush_progress = AsyncMock()
parent_task = SimpleNamespace(id="delivery-task")
run_engine = SimpleNamespace(
project_id="project-a",
store=SimpleNamespace(get_task=AsyncMock(return_value=parent_task)),
run_company_delivery_self_evolution_checkpoint=AsyncMock(return_value="ran"),
)
await handler._process_company_delivery_feedback_reply(
parent_task_id="delivery-task",
parent_session_id="delivery-session",
reply_channel_id="session:delivery-task",
content="approved",
attachment_refs=None,
message_metadata=None,
user_message_id=None,
user_message_created_at=None,
run_engine=run_engine,
run_project_id="project-a",
checkpoint=SimpleNamespace(checkpoint_id="cp-delivery", payload={}),
waiting_task_id="delivery-task",
lock=asyncio.Lock(),
)
call = run_engine.run_company_delivery_self_evolution_checkpoint.await_args
assert call is not None
assert call.kwargs["action"] == "approve"
handler._resolve_task_org_id.assert_called_once_with(parent_task)
asyncio.run(scenario())
def test_suspend_reply_missing_custom_org_id_fails_closed_before_engine_call() -> None:
async def scenario() -> None:
_tasks, checkpoint = _runtime_records()
handler = WSHandler.__new__(WSHandler)
handler._exec_mode = "task"
handler._company_profile = "corporate"
handler._shutting_down = False
handler.chat_store = None
handler._company_suspend_reply_locks = {
"runtime-session": asyncio.Lock(),
}
handler._task_bg_context = {}
handler._active_runtime_children = {}
handler._session_to_task = {}
handler._set_company_runtime_control = AsyncMock()
handler._normalize_session_exec_mode = MagicMock(return_value="task")
handler._normalize_session_company_profile = MagicMock(return_value="corporate")
handler._resolve_task_session_config = MagicMock(return_value=("org", "custom"))
handler._resolve_task_org_id = MagicMock(return_value="")
handler._extract_checkpoint_metadata = AsyncMock(return_value=None)
handler._sync_task_transcript_messages = AsyncMock()
handler.on_kanban_changed = AsyncMock()
handler._flush_progress = AsyncMock()
run_engine = SimpleNamespace(
project_id="project-a",
process_message=AsyncMock(),
)
config_task = SimpleNamespace(
metadata={"exec_mode": "org", "company_profile": "custom"},
org_id=None,
)
target = {
"ui_anchor_task_id": "ui-anchor",
"config_task": config_task,
}
await handler._process_company_suspend_reply(
ui_task_id="final-decider",
runtime_session_id="runtime-session",
content="continue",
attachment_refs=None,
message_metadata=None,
user_message_id=None,
user_message_created_at=None,
run_engine=run_engine,
run_project_id="project-a",
target=target,
checkpoint=checkpoint,
lock=handler._company_suspend_reply_locks["runtime-session"],
)
run_engine.process_message.assert_not_awaited()
handler._set_company_runtime_control.assert_not_awaited()
asyncio.run(scenario())
def test_delivery_feedback_rejects_missing_canonical_identity_without_first_task_fallback() -> None:
async def scenario() -> None:
tasks, checkpoint = _runtime_records()
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from opc.core.config import LLMConfig
from opc.llm.provider import LLMProvider
def _completion_response() -> SimpleNamespace:
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content="ok", tool_calls=[]),
finish_reason="stop",
)
],
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1),
)
async def _completion_stream():
yield SimpleNamespace(
usage=None,
choices=[
SimpleNamespace(
delta=SimpleNamespace(
content="ok",
reasoning=None,
reasoning_content=None,
thinking=None,
tool_calls=[],
),
finish_reason="stop",
)
],
)
class TestLLMProviderReasoningEffort(unittest.IsolatedAsyncioTestCase):
def test_config_retains_reasoning_effort(self) -> None:
config = LLMConfig.model_validate({
"default_model": "openai/gpt-5.6-luna",
"reasoning_effort": "max",
})
assert config.reasoning_effort == "max"
async def test_chat_forwards_configured_reasoning_effort(self) -> None:
provider = LLMProvider(LLMConfig(
default_model="openai/gpt-5.6-luna",
reasoning_effort="max",
))
with (
patch("opc.llm.provider._clamp_max_tokens", return_value=128),
patch(
"opc.llm.provider.litellm.acompletion",
new=AsyncMock(return_value=_completion_response()),
) as completion,
):
await provider.chat([{"role": "user", "content": "hello"}])
assert completion.await_args.kwargs["reasoning_effort"] == "max"
async def test_chat_allows_per_call_reasoning_effort_override(self) -> None:
provider = LLMProvider(LLMConfig(
default_model="openai/gpt-5.6-luna",
reasoning_effort="high",
))
with (
patch("opc.llm.provider._clamp_max_tokens", return_value=128),
patch(
"opc.llm.provider.litellm.acompletion",
new=AsyncMock(return_value=_completion_response()),
) as completion,
):
await provider.chat(
[{"role": "user", "content": "hello"}],
reasoning_effort="low",
)
assert completion.await_args.kwargs["reasoning_effort"] == "low"
async def test_chat_stream_forwards_configured_reasoning_effort(self) -> None:
provider = LLMProvider(LLMConfig(
default_model="openai/gpt-5.6-luna",
reasoning_effort="max",
))
with (
patch("opc.llm.provider._clamp_max_tokens", return_value=128),
patch(
"opc.llm.provider.litellm.acompletion",
new=AsyncMock(return_value=_completion_stream()),
) as completion,
):
events = [
event
async for event in provider.chat_stream([
{"role": "user", "content": "hello"},
])
]
assert events
assert completion.await_args.kwargs["reasoning_effort"] == "max"
assert completion.await_args.kwargs["stream"] is True
async def test_chat_stream_allows_per_call_reasoning_effort_override(self) -> None:
provider = LLMProvider(LLMConfig(
default_model="openai/gpt-5.6-luna",
reasoning_effort="high",
))
with (
patch("opc.llm.provider._clamp_max_tokens", return_value=128),
patch(
"opc.llm.provider.litellm.acompletion",
new=AsyncMock(return_value=_completion_stream()),
) as completion,
):
events = [
event
async for event in provider.chat_stream(
[{"role": "user", "content": "hello"}],
reasoning_effort="low",
)
]
assert events
assert completion.await_args.kwargs["reasoning_effort"] == "low"
async def test_unset_reasoning_effort_is_not_added_to_requests(self) -> None:
provider = LLMProvider(LLMConfig(default_model="openai/gpt-5.6-luna"))
with (
patch("opc.llm.provider._clamp_max_tokens", return_value=128),
patch(
"opc.llm.provider.litellm.acompletion",
new=AsyncMock(return_value=_completion_response()),
) as completion,
):
await provider.chat([{"role": "user", "content": "hello"}])
assert "reasoning_effort" not in completion.await_args.kwargs
+132 -1
View File
@@ -49,11 +49,20 @@ class _EngineStub:
return self.reply
def _pending_checkpoint(checkpoint_id: str, checkpoint_type: str = "task_user_input") -> Any:
def _pending_checkpoint(
checkpoint_id: str,
checkpoint_type: str = "task_user_input",
*,
task_id: str = "chat-task",
session_id: str = "session-1",
) -> Any:
return SimpleNamespace(
checkpoint_id=checkpoint_id,
checkpoint_type=checkpoint_type,
status="pending",
task_id=task_id,
session_id=session_id,
payload={"task_ids": [task_id], "waiting_task_id": task_id, "session_id": session_id},
)
@@ -189,6 +198,30 @@ class LockFreeCheckpointAnswerTests(unittest.IsolatedAsyncioTestCase):
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_lock_free_requires_exact_checkpoint_type_and_owner(self) -> None:
engine = _EngineStub(
_StoreStub([
_pending_checkpoint(
"ckpt-park",
"company_work_item_gate",
task_id="other-task",
session_id="other-session",
)
])
)
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine,
**_answer_kwargs(),
)
self.assertFalse(handled)
self.assertEqual(engine.calls, [])
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_engine_failure_surfaces_error_without_queueing(self) -> None:
engine = _EngineStub(
_StoreStub([_pending_checkpoint("ckpt-park")]),
@@ -230,6 +263,104 @@ class LockFreeCheckpointAnswerTests(unittest.IsolatedAsyncioTestCase):
self.assertFalse(handled)
self.assertEqual(engine.calls, [])
async def test_anchor_channel_answer_for_role_task_gate_is_handled(self) -> None:
# Project-0012 production shape: a company gate checkpoint is raised
# by a role work-item task, but the card is answered from the run's
# anchor chat channel. The anchor task id only appears in
# payload["task_ids"]; exact task/session equality would reject it
# and re-open the late-approval lock wedge.
checkpoint = SimpleNamespace(
checkpoint_id="ckpt-park",
checkpoint_type="company_work_item_gate",
status="pending",
task_id="role-task",
session_id="role-session",
payload={
"waiting_task_id": "role-task",
"session_id": "role-session",
"task_ids": ["role-task", "chat-task"],
},
)
engine = _EngineStub(_StoreStub([checkpoint]))
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine,
**_answer_kwargs(
message_metadata={
"response_to_checkpoint_id": "ckpt-park",
"response_to_checkpoint_type": "company_work_item_gate",
},
),
)
self.assertTrue(handled)
self.assertEqual(len(engine.calls), 1)
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
async def test_legacy_checkpoint_without_linkage_is_still_handled(self) -> None:
# Checkpoints persisted before ownership fields existed carry no
# task/session linkage at all. They must keep the pre-scoping
# behavior (deliver by explicit checkpoint id) instead of silently
# falling back to the wedged serialized path.
checkpoint = SimpleNamespace(
checkpoint_id="ckpt-park",
checkpoint_type="task_user_input",
status="pending",
payload={},
)
engine = _EngineStub(_StoreStub([checkpoint]))
handler = _make_handler(engine)
holder = await self._hold_lock(handler, "chat-task")
try:
handled = await handler._try_lock_free_parked_checkpoint_answer(
engine=engine, **_answer_kwargs()
)
self.assertTrue(handled)
self.assertEqual(len(engine.calls), 1)
finally:
holder.release_event.set() # type: ignore[attr-defined]
await holder
class SessionIdentityErrorSurfacingTests(unittest.IsolatedAsyncioTestCase):
async def test_identity_service_error_surfaces_in_chat_instead_of_raising(self) -> None:
# _process_session_message mostly runs as a fire-and-forget background
# task; a ServiceError escaping it is only logged and the user's
# message silently vanishes. The pre-lock identity resolution must
# surface the failure as a visible chat error and stop.
from opc.plugins.office_ui.services.models import ServiceError
class _Store:
async def get_task(self, task_id: str) -> Any:
return SimpleNamespace(id=task_id, session_id="sess-1", metadata={})
engine = _EngineStub(_Store())
handler = _make_handler(engine)
handler.engine = engine
handler._session_to_task = {}
handler._exec_mode = "company"
handler._company_profile = "corporate"
handler._task_preferred_agent = "native"
async def _raise_identity_error(*args: Any, **kwargs: Any) -> Any:
raise ServiceError(
"company_runtime_identity_mismatch",
"Company runtime identity could not be resolved",
{"task_id": "chat-task"},
)
handler._resolve_session_runtime_config_task = _raise_identity_error
await handler._process_session_message("chat-task", "please continue")
self.assertEqual(engine.calls, [])
errors = [m for m in handler.chat_store.inserted if m.get("sender") == "system"]
self.assertEqual(len(errors), 1)
self.assertIn("Company runtime identity could not be resolved", errors[0]["content"])
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -112,6 +112,21 @@ def test_task_org_id_field_is_org_identity_fallback() -> None:
assert identity.org_id == "quantum_harbor"
def test_durable_task_org_id_wins_over_stale_metadata_org_id() -> None:
task = SimpleNamespace(
metadata={
"exec_mode": "org",
"company_profile": "custom",
"organization_id": "active-org",
},
org_id="selected-org",
)
identity = execution_identity_from_task(task)
assert identity.org_id == "selected-org"
def test_default_org_id_applies_only_when_task_has_no_persisted_identity() -> None:
task = SimpleNamespace(metadata={}, org_id=None)
@@ -146,3 +161,42 @@ def test_explicit_company_identity_ignores_default_org_id() -> None:
assert identity.exec_mode == "company"
assert identity.company_profile == "corporate"
assert identity.org_id == ""
def test_company_mode_marker_without_profile_is_company_identity() -> None:
task = SimpleNamespace(
metadata={"mode": "company"},
org_id=None,
)
identity = execution_identity_from_task(task)
assert identity.exec_mode == "company"
assert identity.company_profile == "corporate"
assert identity.org_id == ""
def test_work_item_runtime_marker_without_profile_is_company_identity() -> None:
task = SimpleNamespace(
metadata={"work_item_runtime": True},
org_id=None,
)
identity = execution_identity_from_task(task)
assert identity.exec_mode == "company"
assert identity.company_profile == "corporate"
assert identity.org_id == ""
def test_company_runtime_marker_with_custom_profile_is_org_identity() -> None:
task = SimpleNamespace(
metadata={"mode": "company", "work_item_runtime": True, "company_profile": "custom"},
org_id=None,
)
identity = execution_identity_from_task(task)
assert identity.exec_mode == "org"
assert identity.company_profile == "custom"
assert identity.org_id == ""
+15 -21
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import unittest
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
from opc.plugins.office_ui.services.context import OfficeServiceContext
from opc.plugins.office_ui.services.models import ServiceError
@@ -40,7 +41,7 @@ def _context(*, hook: Any | None = None) -> OfficeServiceContext:
return context
class TestPersistSessionConfigOrgFallback(unittest.IsolatedAsyncioTestCase):
class TestPersistSessionConfigOrgIdentity(unittest.IsolatedAsyncioTestCase):
async def _persist(
self,
context: OfficeServiceContext,
@@ -58,26 +59,20 @@ class TestPersistSessionConfigOrgFallback(unittest.IsolatedAsyncioTestCase):
org_id=org_id,
)
async def test_falls_back_to_active_saved_org_when_task_lacks_org_id(self) -> None:
async def active_org() -> str:
return "vc-investment-firm"
async def test_rejects_missing_org_id_without_active_org_fallback(self) -> None:
active_org = AsyncMock(return_value="vc-investment-firm")
context = _context(hook=active_org)
task = _task()
await self._persist(context, task)
with self.assertRaises(ServiceError) as ctx:
await self._persist(context, task)
assert task.metadata["org_id"] == "vc-investment-firm"
assert task.metadata["organization_id"] == "vc-investment-firm"
assert task.org_id == "vc-investment-firm"
assert task.metadata["exec_mode"] == "org"
assert task.metadata["company_profile"] == "custom"
assert context.engine.store.saved == [task]
assert ctx.exception.code == "org_id_required"
active_org.assert_not_awaited()
assert context.engine.store.saved == []
async def test_explicit_org_id_still_used_when_present(self) -> None:
async def active_org() -> str:
return "other-org"
active_org = AsyncMock(return_value="other-org")
context = _context(hook=active_org)
task = _task()
@@ -85,17 +80,17 @@ class TestPersistSessionConfigOrgFallback(unittest.IsolatedAsyncioTestCase):
assert task.metadata["org_id"] == "vc-investment-firm"
assert task.org_id == "vc-investment-firm"
active_org.assert_not_awaited()
async def test_raises_when_no_active_org_available(self) -> None:
async def empty_org() -> str:
return ""
empty_org = AsyncMock(return_value="")
context = _context(hook=empty_org)
task = _task()
with self.assertRaises(ServiceError) as ctx:
await self._persist(context, task)
assert ctx.exception.code == "org_id_required"
empty_org.assert_not_awaited()
async def test_raises_when_hook_unset(self) -> None:
context = _context()
@@ -106,15 +101,14 @@ class TestPersistSessionConfigOrgFallback(unittest.IsolatedAsyncioTestCase):
assert ctx.exception.code == "org_id_required"
async def test_raises_when_hook_fails(self) -> None:
async def broken() -> str:
raise RuntimeError("org index unreadable")
broken = AsyncMock(side_effect=RuntimeError("org index unreadable"))
context = _context(hook=broken)
task = _task()
with self.assertRaises(ServiceError) as ctx:
await self._persist(context, task)
assert ctx.exception.code == "org_id_required"
broken.assert_not_awaited()
async def test_company_mode_clears_org_fields_without_fallback(self) -> None:
fallback_called = False
+29
View File
@@ -13,6 +13,7 @@ from opc.core.org_config import (
)
from opc.engine import OPCEngine
from opc.layer2_organization.custom_runtime import CustomRuntimeRunner
from opc.plugins.office_ui.services.models import ServiceError
def test_requested_mode_normalization_keeps_core_company_router_main_compatible() -> None:
@@ -61,6 +62,34 @@ def test_custom_runtime_runner_loads_org_storage_without_mutating_parent_config(
assert (config_dir / "company_orgs" / "org_lab_config.yaml").exists()
def test_process_message_rejects_org_mode_without_org_id() -> None:
engine = OPCEngine.__new__(OPCEngine)
engine.opc_home = None
engine.config = OPCConfig()
runner = CustomRuntimeRunner(engine)
caught: ServiceError | None = None
async def _run() -> None:
await runner.process_message(
"run org",
project_id="default",
session_id="session-1",
org_id=None,
preferred_agent=None,
domains=None,
origin_task_id=None,
attachment_refs=None,
message_metadata=None,
)
try:
asyncio.run(_run())
except ServiceError as exc:
caught = exc
assert caught is not None
assert caught.code == "org_id_required"
def test_process_message_routes_org_mode_to_custom_runner(monkeypatch) -> None:
engine = OPCEngine.__new__(OPCEngine)
engine._initialized = True
+779 -2
View File
@@ -11,9 +11,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
import yaml
from pydantic import ValidationError
from opc.core.config import AgentsConfig, ExternalAgentConfig, OPCConfig
from opc.core.config import (
AgentsConfig,
DEFAULT_ORGANIZATION_ID,
ExternalAgentConfig,
OPCConfig,
)
from opc.core.models import (
DelegationWorkItem,
ExecutionCheckpoint,
ExecutionMode,
RouterDecision,
SessionMessageRecord,
@@ -24,7 +30,8 @@ from opc.core.models import (
WorkItemExecutionStrategy,
)
from opc.engine import OPCEngine
from opc.layer2_organization.company_mode import CompanyWorkItemExecutor
from opc.layer2_organization.company_mode import CompanyRuntimeSpecBuilder, CompanyWorkItemExecutor
from opc.plugins.office_ui.services.models import ServiceError
from opc.layer3_agent.adapters.claude_code import ClaudeCodeAdapter
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
from opc.layer3_agent.adapters.cursor_adapter import CursorAdapter
@@ -323,6 +330,650 @@ class RuntimeConfigEnforcementTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(task.metadata["preferred_external_agent"], "opencode")
self.assertEqual(task.metadata["work_item_execution_strategy"], WorkItemExecutionStrategy.EXTERNAL.value)
async def test_company_root_task_persists_decision_org_over_active_config(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
engine.store = SimpleNamespace(
get_runtime_task_for_work_item=AsyncMock(return_value=None),
save_delegation_work_item=AsyncMock(),
save_task=AsyncMock(),
link_work_item_runtime_task=AsyncMock(return_value=True),
)
engine.memory = SimpleNamespace(ensure_session=AsyncMock())
engine.org_engine = SimpleNamespace(
current_org_version=MagicMock(return_value=1),
current_runtime_topology_version=MagicMock(return_value=1),
)
engine._requests_explicit_project_knowledge = MagicMock(return_value=False)
work_item = DelegationWorkItem(
work_item_id="wi-selected-org",
run_id="run-selected-org",
cell_id="team::engineering",
team_instance_id="team-instance-1",
role_id="engineer",
seat_id="seat-engineer",
title="Engineering execution",
summary="Implement the requested change.",
kind="execute",
projection_id="engineering-execute",
metadata={"seat_id": "seat-engineer", "team_id": "team::engineering"},
)
task = await engine._ensure_runtime_work_item_task(
work_item=work_item,
parent_session_id="sess-company",
original_message="Build the thing.",
decision=RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
domains=[],
company_profile="custom",
org_id="selected-org",
),
runtime_topology={
"final_decider_role_id": "lead",
"seats": [
{
"seat_id": "seat-engineer",
"team_id": "team::engineering",
"role_id": "engineer",
"employee_assignment": {"employee_id": "eng-1", "name": "Engineer"},
"metadata": {"role_name": "Engineer"},
}
],
},
delegation_playbook={},
secretary_context="",
target_output_dir=None,
origin_channel="cli",
origin_chat_id="",
origin_thread_id="",
origin_task_id=None,
attachment_refs=[],
attachment_context="",
force_native_execution=False,
)
self.assertEqual(task.org_id, "selected-org")
self.assertEqual(task.metadata["org_id"], "selected-org")
self.assertEqual(task.metadata["organization_id"], "selected-org")
def test_build_spec_rejects_custom_run_without_durable_org_id(self) -> None:
builder = CompanyRuntimeSpecBuilder(
org_engine=SimpleNamespace(
get_company_profile=lambda: "custom",
config=SimpleNamespace(
org=SimpleNamespace(
organization_id="active-org",
organization_name="Active Org",
organization_config_file="org_active-org_config.yaml",
company_profile="custom",
)
),
)
)
with self.assertRaises(ServiceError) as ctx:
builder.build_spec(
RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id=None,
),
original_message="Run the company.",
)
assert ctx.exception.code == "org_id_required"
def test_build_spec_serializes_selected_org_not_active_config(self) -> None:
builder = CompanyRuntimeSpecBuilder(
org_engine=SimpleNamespace(
get_company_profile=lambda: "custom",
config=SimpleNamespace(
org=SimpleNamespace(
organization_id="active-org",
organization_name="Active Org",
organization_config_file="org_active-org_config.yaml",
company_profile="custom",
)
),
)
)
decision = RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id="selected-org",
)
spec = builder.build_spec(decision, original_message="Run the company.")
assert spec.metadata["org_id"] == "selected-org"
assert spec.metadata["organization_id"] == "selected-org"
def test_runtime_org_id_for_identity_never_derives_from_active_config(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
decision = RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id=None,
)
resolved = OPCEngine._runtime_org_id_for_identity(
decision,
{},
engine.config.org,
)
assert resolved is None
async def test_ensure_runtime_work_item_task_rejects_custom_without_durable_org(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
engine.store = SimpleNamespace(
get_runtime_task_for_work_item=AsyncMock(return_value=None),
save_delegation_work_item=AsyncMock(),
save_task=AsyncMock(),
link_work_item_runtime_task=AsyncMock(return_value=True),
)
engine.memory = SimpleNamespace(ensure_session=AsyncMock())
engine.org_engine = SimpleNamespace(
current_org_version=MagicMock(return_value=1),
current_runtime_topology_version=MagicMock(return_value=1),
)
engine._requests_explicit_project_knowledge = MagicMock(return_value=False)
work_item = DelegationWorkItem(
work_item_id="wi-no-durable-org",
run_id="run-no-durable-org",
cell_id="team::engineering",
team_instance_id="team-instance-1",
role_id="engineer",
seat_id="seat-engineer",
title="Engineering execution",
summary="Implement the requested change.",
kind="execute",
projection_id="engineering-execute",
metadata={"seat_id": "seat-engineer", "team_id": "team::engineering"},
)
with self.assertRaises(ServiceError) as ctx:
await engine._ensure_runtime_work_item_task(
work_item=work_item,
parent_session_id="sess-company",
original_message="Build the thing.",
decision=RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id=None,
),
runtime_topology={
"final_decider_role_id": "lead",
"seats": [
{
"seat_id": "seat-engineer",
"team_id": "team::engineering",
"role_id": "engineer",
"employee_assignment": {"employee_id": "eng-1", "name": "Engineer"},
"metadata": {"role_name": "Engineer"},
}
],
},
delegation_playbook={},
secretary_context="",
target_output_dir=None,
origin_channel="cli",
origin_chat_id="",
origin_thread_id="",
origin_task_id=None,
attachment_refs=[],
attachment_context="",
force_native_execution=False,
)
assert ctx.exception.code == "org_id_required"
engine.store.save_task.assert_not_awaited()
async def test_ensure_runtime_work_item_task_rejects_existing_custom_task_without_durable_org(self) -> None:
existing = Task(
id="existing-no-org",
title="Engineering execution",
project_id="proj1",
session_id="sess-company:wi-existing-no-org",
assigned_to="engineer",
metadata={
"execution_mode": "company_mode",
"runtime_model": "multi_team_org",
"work_item_runtime": True,
"work_item_projection_id": "engineering-execute",
"work_item_turn_type": "execute",
"company_profile": "custom",
"delegation_seat_id": "seat-engineer",
},
)
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
engine.store = SimpleNamespace(
get_runtime_task_for_work_item=AsyncMock(return_value=existing),
save_delegation_work_item=AsyncMock(),
save_task=AsyncMock(),
link_work_item_runtime_task=AsyncMock(return_value=True),
)
engine.memory = SimpleNamespace(ensure_session=AsyncMock())
work_item = DelegationWorkItem(
work_item_id="wi-existing-no-org",
run_id="run-existing-no-org",
cell_id="team::engineering",
team_instance_id="team-instance-1",
role_id="engineer",
seat_id="seat-engineer",
title="Engineering execution",
summary="Implement the requested change.",
kind="execute",
projection_id="engineering-execute",
metadata={"seat_id": "seat-engineer", "team_id": "team::engineering"},
)
with self.assertRaises(ServiceError) as ctx:
await engine._ensure_runtime_work_item_task(
work_item=work_item,
parent_session_id="sess-company",
original_message="Build the thing.",
decision=RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id=None,
),
runtime_topology={
"final_decider_role_id": "lead",
"seats": [
{
"seat_id": "seat-engineer",
"team_id": "team::engineering",
"role_id": "engineer",
"employee_assignment": {"employee_id": "eng-1", "name": "Engineer"},
"metadata": {"role_name": "Engineer"},
}
],
},
delegation_playbook={},
secretary_context="",
target_output_dir=None,
origin_channel="cli",
origin_chat_id="",
origin_thread_id="",
origin_task_id=None,
attachment_refs=[],
attachment_context="",
force_native_execution=False,
)
assert ctx.exception.code == "org_id_required"
engine.store.save_task.assert_not_awaited()
async def test_ensure_runtime_work_item_task_rejects_conflicting_org_ids(self) -> None:
existing = Task(
id="existing-conflict-org",
title="Engineering execution",
project_id="proj1",
session_id="sess-company:wi-existing-conflict-org",
assigned_to="engineer",
org_id="persisted-org",
metadata={
"execution_mode": "company_mode",
"runtime_model": "multi_team_org",
"work_item_runtime": True,
"work_item_projection_id": "engineering-execute",
"work_item_turn_type": "execute",
"company_profile": "custom",
"organization_id": "persisted-org",
"delegation_seat_id": "seat-engineer",
},
)
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
engine.store = SimpleNamespace(
get_runtime_task_for_work_item=AsyncMock(return_value=existing),
save_delegation_work_item=AsyncMock(),
save_task=AsyncMock(),
link_work_item_runtime_task=AsyncMock(return_value=True),
)
engine.memory = SimpleNamespace(ensure_session=AsyncMock())
work_item = DelegationWorkItem(
work_item_id="wi-existing-conflict-org",
run_id="run-existing-conflict-org",
cell_id="team::engineering",
team_instance_id="team-instance-1",
role_id="engineer",
seat_id="seat-engineer",
title="Engineering execution",
summary="Implement the requested change.",
kind="execute",
projection_id="engineering-execute",
metadata={"seat_id": "seat-engineer", "team_id": "team::engineering"},
)
with self.assertRaises(ServiceError) as ctx:
await engine._ensure_runtime_work_item_task(
work_item=work_item,
parent_session_id="sess-company",
original_message="Build the thing.",
decision=RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id="selected-org",
),
runtime_topology={
"final_decider_role_id": "lead",
"seats": [
{
"seat_id": "seat-engineer",
"team_id": "team::engineering",
"role_id": "engineer",
"employee_assignment": {"employee_id": "eng-1", "name": "Engineer"},
"metadata": {"role_name": "Engineer"},
}
],
},
delegation_playbook={},
secretary_context="",
target_output_dir=None,
origin_channel="cli",
origin_chat_id="",
origin_thread_id="",
origin_task_id=None,
attachment_refs=[],
attachment_context="",
force_native_execution=False,
)
assert ctx.exception.code == "org_id_conflict"
engine.store.save_task.assert_not_awaited()
async def test_ensure_runtime_work_item_task_repairs_existing_custom_task_from_persisted_durable_org(self) -> None:
existing = Task(
id="existing-durable-org",
title="Engineering execution",
project_id="proj1",
session_id="sess-company:wi-existing-durable-org",
assigned_to="engineer",
org_id="persisted-org",
metadata={
"execution_mode": "company_mode",
"runtime_model": "multi_team_org",
"work_item_runtime": True,
"work_item_projection_id": "engineering-execute",
"work_item_turn_type": "execute",
"company_profile": "custom",
"organization_id": "stale-org",
"delegation_seat_id": "seat-engineer",
},
)
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
engine.store = SimpleNamespace(
get_runtime_task_for_work_item=AsyncMock(return_value=existing),
save_delegation_work_item=AsyncMock(),
save_task=AsyncMock(),
link_work_item_runtime_task=AsyncMock(return_value=True),
)
engine.memory = SimpleNamespace(ensure_session=AsyncMock())
work_item = DelegationWorkItem(
work_item_id="wi-existing-durable-org",
run_id="run-existing-durable-org",
cell_id="team::engineering",
team_instance_id="team-instance-1",
role_id="engineer",
seat_id="seat-engineer",
title="Engineering execution",
summary="Implement the requested change.",
kind="execute",
projection_id="engineering-execute",
metadata={"seat_id": "seat-engineer", "team_id": "team::engineering"},
)
repaired = await engine._ensure_runtime_work_item_task(
work_item=work_item,
parent_session_id="sess-company",
original_message="Build the thing.",
decision=RouterDecision(
mode=ExecutionMode.COMPANY_MODE,
company_profile="custom",
org_id=None,
),
runtime_topology={
"final_decider_role_id": "lead",
"seats": [
{
"seat_id": "seat-engineer",
"team_id": "team::engineering",
"role_id": "engineer",
"employee_assignment": {"employee_id": "eng-1", "name": "Engineer"},
"metadata": {"role_name": "Engineer"},
}
],
},
delegation_playbook={},
secretary_context="",
target_output_dir=None,
origin_channel="cli",
origin_chat_id="",
origin_thread_id="",
origin_task_id=None,
attachment_refs=[],
attachment_context="",
force_native_execution=False,
)
self.assertEqual(repaired.id, "existing-durable-org")
self.assertEqual(repaired.org_id, "persisted-org")
self.assertEqual(repaired.metadata["org_id"], "persisted-org")
self.assertEqual(repaired.metadata["organization_id"], "persisted-org")
engine.store.save_task.assert_awaited_with(existing)
async def test_delivery_self_evolution_custom_without_durable_org_fails_closed(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
waiting_task = Task(
id="waiting-custom",
project_id="proj1",
session_id="sess-delivery",
metadata={
"execution_mode": "company_mode",
"company_profile": "custom",
"work_item_runtime": True,
"work_item_projection_id": "delivery",
"work_item_turn_type": "deliver",
},
)
engine.store = SimpleNamespace(get_task=AsyncMock(return_value=waiting_task))
engine._mark_company_runtime_checkpoint_status = AsyncMock()
engine._create_company_self_evolution_root_work_item = AsyncMock()
checkpoint = ExecutionCheckpoint(
checkpoint_id="cp-delivery",
project_id="proj1",
session_id="sess-delivery",
task_id="waiting-custom",
checkpoint_type="company_delivery_feedback",
payload={"waiting_task_id": "waiting-custom", "task_ids": ["waiting-custom"]},
)
result = await engine._run_company_delivery_self_evolution_consumed(
checkpoint,
action="approve",
)
assert "durable org identity" in result
engine._mark_company_runtime_checkpoint_status.assert_awaited_once_with(
checkpoint,
status="invalid",
)
engine._create_company_self_evolution_root_work_item.assert_not_awaited()
async def test_delivery_self_evolution_custom_uses_durable_org_over_payload_and_config(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
waiting_task = Task(
id="waiting-custom-2",
project_id="proj1",
session_id="sess-delivery",
org_id="selected-org",
metadata={
"execution_mode": "company_mode",
"company_profile": "custom",
"work_item_runtime": True,
"work_item_projection_id": "delivery",
"work_item_turn_type": "deliver",
},
)
engine.store = SimpleNamespace(get_task=AsyncMock(return_value=waiting_task))
engine._mark_company_runtime_checkpoint_status = AsyncMock()
engine.org_engine = None
engine._company_followup_target_task = MagicMock(
return_value=SimpleNamespace(assigned_to="lead", metadata={}),
)
engine.company_executor = SimpleNamespace()
engine._self_evolution_assignments_by_role = MagicMock(return_value={})
engine._create_company_self_evolution_root_work_item = AsyncMock(return_value=None)
checkpoint = ExecutionCheckpoint(
checkpoint_id="cp-delivery-2",
project_id="proj1",
session_id="sess-delivery",
task_id="waiting-custom-2",
checkpoint_type="company_delivery_feedback",
payload={
"waiting_task_id": "waiting-custom-2",
"task_ids": ["waiting-custom-2"],
"organization_id": "stale-org",
},
)
await engine._run_company_delivery_self_evolution_consumed(
checkpoint,
action="approve",
)
call = engine._create_company_self_evolution_root_work_item.await_args
assert call is not None
assert call.kwargs["organization_id"] == "selected-org"
async def test_delivery_self_evolution_corporate_still_uses_config_default(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
waiting_task = Task(
id="waiting-corporate",
project_id="proj1",
session_id="sess-delivery",
metadata={
"execution_mode": "company_mode",
"company_profile": "corporate",
"work_item_runtime": True,
"work_item_projection_id": "delivery",
"work_item_turn_type": "deliver",
},
)
engine.store = SimpleNamespace(get_task=AsyncMock(return_value=waiting_task))
engine._mark_company_runtime_checkpoint_status = AsyncMock()
engine.org_engine = None
engine._company_followup_target_task = MagicMock(
return_value=SimpleNamespace(assigned_to="lead", metadata={}),
)
engine.company_executor = SimpleNamespace()
engine._self_evolution_assignments_by_role = MagicMock(return_value={})
engine._create_company_self_evolution_root_work_item = AsyncMock(return_value=None)
checkpoint = ExecutionCheckpoint(
checkpoint_id="cp-delivery-corp",
project_id="proj1",
session_id="sess-delivery",
task_id="waiting-corporate",
checkpoint_type="company_delivery_feedback",
payload={"waiting_task_id": "waiting-corporate", "task_ids": ["waiting-corporate"]},
)
await engine._run_company_delivery_self_evolution_consumed(
checkpoint,
action="approve",
)
call = engine._create_company_self_evolution_root_work_item.await_args
assert call is not None
assert call.kwargs["organization_id"] == DEFAULT_ORGANIZATION_ID
async def test_delivery_self_evolution_metadata_only_legacy_custom_uses_metadata_org(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
waiting_task = Task(
id="waiting-legacy-metadata",
project_id="proj1",
session_id="sess-delivery",
metadata={
"work_item_runtime": True,
"work_item_projection_id": "delivery",
"work_item_turn_type": "deliver",
"org_id": "selected-org",
},
)
engine.store = SimpleNamespace(get_task=AsyncMock(return_value=waiting_task))
engine._mark_company_runtime_checkpoint_status = AsyncMock()
engine.org_engine = None
engine._company_followup_target_task = MagicMock(
return_value=SimpleNamespace(assigned_to="lead", metadata={}),
)
engine.company_executor = SimpleNamespace()
engine._self_evolution_assignments_by_role = MagicMock(return_value={})
engine._create_company_self_evolution_root_work_item = AsyncMock(return_value=None)
checkpoint = ExecutionCheckpoint(
checkpoint_id="cp-delivery-legacy",
project_id="proj1",
session_id="sess-delivery",
task_id="waiting-legacy-metadata",
checkpoint_type="company_delivery_feedback",
payload={
"waiting_task_id": "waiting-legacy-metadata",
"task_ids": ["waiting-legacy-metadata"],
},
)
await engine._run_company_delivery_self_evolution_consumed(
checkpoint,
action="approve",
)
call = engine._create_company_self_evolution_root_work_item.await_args
assert call is not None
assert call.kwargs["organization_id"] == "selected-org"
async def test_delivery_self_evolution_conflicting_org_ids_fail_closed(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.config.org.company_profile = "custom"
engine.config.org.organization_id = "active-org"
waiting_task = Task(
id="waiting-conflict",
project_id="proj1",
session_id="sess-delivery",
org_id="selected-org",
metadata={
"work_item_runtime": True,
"work_item_projection_id": "delivery",
"work_item_turn_type": "deliver",
"org_id": "other-org",
},
)
engine.store = SimpleNamespace(get_task=AsyncMock(return_value=waiting_task))
engine._mark_company_runtime_checkpoint_status = AsyncMock()
engine.org_engine = None
engine._company_followup_target_task = MagicMock(
return_value=SimpleNamespace(assigned_to="lead", metadata={}),
)
engine.company_executor = SimpleNamespace()
engine._self_evolution_assignments_by_role = MagicMock(return_value={})
engine._create_company_self_evolution_root_work_item = AsyncMock(return_value=None)
checkpoint = ExecutionCheckpoint(
checkpoint_id="cp-delivery-conflict",
project_id="proj1",
session_id="sess-delivery",
task_id="waiting-conflict",
checkpoint_type="company_delivery_feedback",
payload={
"waiting_task_id": "waiting-conflict",
"task_ids": ["waiting-conflict"],
},
)
result = await engine._run_company_delivery_self_evolution_consumed(
checkpoint,
action="approve",
)
assert "org identity" in result
engine._mark_company_runtime_checkpoint_status.assert_awaited_once_with(
checkpoint,
status="invalid",
)
engine._create_company_self_evolution_root_work_item.assert_not_awaited()
async def test_company_materialized_work_item_uses_selected_agent_over_template_preference(self) -> None:
saved_tasks: list[Task] = []
@@ -389,6 +1040,132 @@ class RuntimeConfigEnforcementTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(created.metadata["work_item_execution_strategy"], WorkItemExecutionStrategy.EXTERNAL.value)
self.assertEqual(saved_tasks[0].assigned_external_agent, "cursor")
async def test_company_materialization_persists_durable_org_on_new_role_task(self) -> None:
executor = CompanyWorkItemExecutor(
org_engine=SimpleNamespace(),
communication=SimpleNamespace(),
approval_engine=SimpleNamespace(),
memory=SimpleNamespace(ensure_session=AsyncMock()),
execute_task=AsyncMock(),
save_task=AsyncMock(),
)
executor.store = SimpleNamespace(
get_runtime_task_for_work_item=AsyncMock(return_value=None),
save_delegation_work_item=AsyncMock(),
save_task=AsyncMock(),
link_work_item_runtime_task=AsyncMock(return_value=True),
)
root_task = Task(
id="root-custom-org",
title="Root",
project_id="proj1",
session_id="sess-company",
org_id="selected-org",
metadata={
"execution_mode": "company_mode",
"runtime_model": "multi_team_org",
"company_profile": "custom",
"organization_id": "active-org",
"runtime_topology": {
"seats": [
{
"seat_id": "seat-engineer",
"team_id": "team::engineering",
"role_id": "engineer",
"metadata": {"role_name": "Engineer"},
}
]
},
},
)
work_item = DelegationWorkItem(
work_item_id="wi-new-custom-org",
run_id="run-custom-org",
cell_id="team::engineering",
team_instance_id="team-instance-1",
role_id="engineer",
seat_id="seat-engineer",
title="Engineering execution",
summary="Implement the requested change.",
kind="execute",
projection_id="engineering-execute",
metadata={"seat_id": "seat-engineer", "team_id": "team::engineering"},
)
tasks = await executor._materialize_work_item_tasks([root_task], [work_item])
created = next(task for task in tasks if task.id != root_task.id)
self.assertEqual(created.org_id, "selected-org")
self.assertEqual(created.metadata["org_id"], "selected-org")
self.assertEqual(created.metadata["organization_id"], "selected-org")
async def test_company_materialization_repairs_existing_role_task_org_identity(self) -> None:
existing = Task(
id="existing-custom-role",
title="Engineering execution",
project_id="proj1",
session_id="sess-company:wi-existing-custom-org",
assigned_to="engineer",
org_id="active-org",
metadata={
"execution_mode": "company_mode",
"runtime_model": "multi_team_org",
"work_item_runtime": True,
"work_item_projection_id": "engineering-execute",
"work_item_turn_type": "execute",
"company_profile": "custom",
"organization_id": "active-org",
"delegation_seat_id": "seat-engineer",
},
)
executor = CompanyWorkItemExecutor(
org_engine=SimpleNamespace(),
communication=SimpleNamespace(),
approval_engine=SimpleNamespace(),
memory=SimpleNamespace(ensure_session=AsyncMock()),
execute_task=AsyncMock(),
save_task=AsyncMock(),
)
executor.store = SimpleNamespace(
get_runtime_task_for_work_item=AsyncMock(return_value=existing),
save_delegation_work_item=AsyncMock(),
save_task=AsyncMock(),
)
root_task = Task(
id="root-custom-org-existing",
title="Root",
project_id="proj1",
session_id="sess-company",
org_id="selected-org",
metadata={
"execution_mode": "company_mode",
"runtime_model": "multi_team_org",
"company_profile": "custom",
"organization_id": "active-org",
},
)
work_item = DelegationWorkItem(
work_item_id="wi-existing-custom-org",
run_id="run-custom-org",
cell_id="team::engineering",
team_instance_id="team-instance-1",
role_id="engineer",
seat_id="seat-engineer",
title="Engineering execution",
summary="Implement the requested change.",
kind="execute",
projection_id="engineering-execute",
metadata={"seat_id": "seat-engineer", "team_id": "team::engineering"},
)
tasks = await executor._materialize_work_item_tasks([root_task], [work_item])
repaired = next(task for task in tasks if task.id == existing.id)
self.assertEqual(repaired.org_id, "selected-org")
self.assertEqual(repaired.metadata["org_id"], "selected-org")
self.assertEqual(repaired.metadata["organization_id"], "selected-org")
executor.store.save_task.assert_any_await(existing)
async def test_task_mode_external_followup_reuses_primary_session_external_agent_session(self) -> None:
engine = OPCEngine(config=OPCConfig(), project_id="proj1")
engine.store = SimpleNamespace(
+163 -2
View File
@@ -2007,8 +2007,10 @@ class TestWSHandlerSessionSend(unittest.IsolatedAsyncioTestCase):
"exec_mode": "org",
"mode": "company",
"company_profile": "custom",
"org_id": "vc-investment-firm",
"org_id": "wrong-active-org",
"organization_id": "wrong-active-org",
}
anchor.org_id = "vc-investment-firm"
await self.store.save_task(anchor)
role_task = Task(
@@ -2017,10 +2019,13 @@ class TestWSHandlerSessionSend(unittest.IsolatedAsyncioTestCase):
project_id="test-project",
session_id=f"{runtime_session_id}:role:sector-analyst",
parent_session_id=runtime_session_id,
org_id="vc-investment-firm",
metadata={
"exec_mode": "org",
"mode": "company",
"company_profile": "custom",
"org_id": "wrong-active-org",
"organization_id": "wrong-active-org",
"shared_role_session": True,
"shared_role_id": "sector_analyst",
"company_runtime_root_session_id": runtime_session_id,
@@ -2043,6 +2048,96 @@ class TestWSHandlerSessionSend(unittest.IsolatedAsyncioTestCase):
self.assertEqual(call_kwargs["company_profile"], "custom")
self.assertEqual(call_kwargs["org_id"], "vc-investment-firm")
async def test_process_session_message_fails_closed_when_runtime_identity_is_missing(self) -> None:
task = await self.store.get_task(self.task_id)
assert task is not None
task.metadata = {
"mode": "company",
"company_profile": "custom",
}
await self.store.save_task(task)
self.handler._resolve_company_runtime_target = AsyncMock(return_value=None)
self.handler.services_context.get_active_saved_org_name = AsyncMock(
return_value="wrong-active-org"
)
# Fail closed without raising: this coroutine usually runs as a
# fire-and-forget background task where an escaping ServiceError is
# only logged and the user's message silently vanishes. The rejection
# must instead surface as a visible chat error.
await self.handler._process_session_message(
self.task_id,
"approve",
session_id=self.session_id,
)
self.engine.process_message.assert_not_called()
self.handler.services_context.get_active_saved_org_name.assert_not_awaited()
errors = [
msg["payload"].get("content", "")
for msg in self.broadcasts
if msg.get("type") == "session_message"
and str(msg.get("payload", {}).get("sender", "")) == "system"
]
self.assertTrue(
any("Company runtime identity could not be resolved" in text for text in errors),
errors,
)
async def test_process_session_message_rejects_runtime_org_without_durable_org_id(self) -> None:
runtime_session_id = "runtime-org-missing-id-session"
anchor = await self.store.get_task(self.task_id)
assert anchor is not None
anchor.session_id = runtime_session_id
anchor.metadata = {
"exec_mode": "org",
"mode": "company",
"company_profile": "custom",
}
await self.store.save_task(anchor)
role_task = Task(
id="role-task-missing-org-id",
title="Sector Analyst",
project_id="test-project",
session_id=f"{runtime_session_id}:role:sector-analyst",
parent_session_id=runtime_session_id,
metadata={
"exec_mode": "org",
"mode": "company",
"company_profile": "custom",
"shared_role_session": True,
"shared_role_id": "sector_analyst",
"company_runtime_root_session_id": runtime_session_id,
},
)
await self.store.save_task(role_task)
self.handler.services_context.get_active_saved_org_name = AsyncMock(
return_value="wrong-active-org"
)
# Same fail-closed-without-raising contract as the identity-mismatch
# case above: reject visibly instead of raising out of a background
# task.
await self.handler._process_session_message(
role_task.id,
"approve",
session_id=role_task.session_id,
)
self.engine.process_message.assert_not_called()
self.handler.services_context.get_active_saved_org_name.assert_not_awaited()
errors = [
msg["payload"].get("content", "")
for msg in self.broadcasts
if msg.get("type") == "session_message"
and str(msg.get("payload", {}).get("sender", "")) == "system"
]
self.assertTrue(
any("org_id_required" in text for text in errors),
errors,
)
async def test_lock_free_process_session_message_uses_durable_org_for_role_task(self) -> None:
runtime_session_id = "runtime-org-lock-free-session"
anchor = await self.store.get_task(self.task_id)
@@ -2075,10 +2170,15 @@ class TestWSHandlerSessionSend(unittest.IsolatedAsyncioTestCase):
checkpoint = ExecutionCheckpoint(
checkpoint_id="org-lock-free-gate",
project_id="test-project",
session_id=runtime_session_id,
session_id=role_task.session_id,
checkpoint_type="company_work_item_gate",
status="pending",
task_id=role_task.id,
payload={
"waiting_task_id": role_task.id,
"task_ids": [role_task.id],
"session_id": role_task.session_id,
},
)
await self.store.save_execution_checkpoint(checkpoint)
self.engine._load_execution_checkpoint_by_id = AsyncMock(return_value=checkpoint)
@@ -5405,6 +5505,41 @@ class TestOfficeServiceExecutionIdentity(unittest.IsolatedAsyncioTestCase):
assert persisted is not None
self.assertEqual(persisted.status, TaskStatus.RUNNING)
async def test_continue_rejects_runtime_custom_org_without_durable_org_id(self) -> None:
task = Task(
id="service-continue-org-missing-id",
title="Broken custom runtime",
session_id="service-continue-org-runtime",
project_id="test-project",
metadata={"exec_mode": "org", "company_profile": "custom"},
)
checkpoint = ExecutionCheckpoint(
checkpoint_id="service-continue-org-checkpoint",
project_id="test-project",
session_id=task.session_id,
checkpoint_type="company_runtime_suspended",
status="pending",
task_id=task.id,
)
await self.store.save_task(task)
self.session_service._resolve_company_runtime_target = AsyncMock(return_value={
"runtime_session_id": task.session_id,
"ui_anchor_task_id": task.id,
"config_task": task,
"checkpoint": checkpoint,
"affected_task_ids": [task.id],
})
with self.assertRaises(ServiceError) as raised:
await self.session_service.continue_run(
project_id="test-project",
task_id=task.id,
content="continue",
)
self.assertEqual(raised.exception.code, "org_id_required")
self.engine.process_message.assert_not_called()
async def test_session_send_from_work_item_uses_runtime_checkpoint_identity(self) -> None:
anchor = Task(
id="service-send-anchor",
@@ -5835,6 +5970,32 @@ class TestWSHandlerRunTask(unittest.IsolatedAsyncioTestCase):
self.assertEqual(call_kwargs["company_profile"], "custom")
self.assertEqual(call_kwargs["org_id"], "quantum_harbor")
async def test_run_task_fails_closed_for_custom_identity_without_org_id(self) -> None:
task_id = str(uuid.uuid4())
session_id = str(uuid.uuid4())
task = Task(
id=task_id,
title="Missing Org Runtime",
session_id=session_id,
project_id="test-project",
metadata={"exec_mode": "org", "company_profile": "custom"},
)
await self.store.save_task(task)
self.handler.services_context.get_active_saved_org_name = AsyncMock(
return_value="wrong-active-org"
)
await self.handler._run_task(
"Missing Org Runtime",
"Description",
"org",
"custom",
task_id,
)
self.engine.process_message.assert_not_called()
self.handler.services_context.get_active_saved_org_name.assert_not_awaited()
async def test_run_task_explicit_company_clears_stale_custom_fields(self) -> None:
task_id = str(uuid.uuid4())
session_id = str(uuid.uuid4())