diff --git a/.agents/skills/nexent-python-tests/SKILL.md b/.agents/skills/nexent-python-tests/SKILL.md index d8bd25447f..31a7d4bb3d 100644 --- a/.agents/skills/nexent-python-tests/SKILL.md +++ b/.agents/skills/nexent-python-tests/SKILL.md @@ -1,12 +1,14 @@ --- name: nexent-python-tests -description: Use when writing, debugging, or reviewing Nexent Python unit tests under test/backend, test/sdk, or other test Python modules. Covers pytest fixtures, lookup-site mocking, async behavior, isolation, and regression assertions. Skip frontend checks and live-service functional or model-runtime verification. +description: Maintain the legacy implementation-oriented Nexent Python unit tests under test/backend, test/sdk, and test/ext_components. Use for pytest fixtures, lookup-site mocking, async behavior, isolation, and regression assertions in those existing suites. Do not create or maintain the requirement-driven D1-D5 baseline, automation, or manifest. --- -# Nexent Python unit tests +# Nexent legacy Python unit tests Paths below are relative to the repository root. +These tests are a legacy suite kept separate from the requirement-driven D1-D5 system. Do not assign formal D1-D5 case IDs to them, add them to `test/manifests/d1-d5.yaml`, or count them in the generated functional baseline. Use `nexent-test-assets` for new D1-D5 work. + 1. Identify the unit and behavior. Inspect neighboring tests, `test/conftest.py`, and `test/pytest.ini` before changing fixture/import setup. 2. Use pytest exclusively, pytest assertions, fixtures, and `pytest-mock`. Files/functions start with `test_`; test classes start with `Test`. Keep files below 500 lines or split by feature using `test__.py`; split package directories include `__init__.py`. 3. Import the unit and necessary test helpers. Mock collaborators rather than exercising external interfaces/clients/services. Patch the fully qualified lookup site determined from actual imports, not the dependency's definition module. diff --git a/.agents/skills/nexent-spec-coding/SKILL.md b/.agents/skills/nexent-spec-coding/SKILL.md index 4c6ae1cd9d..749d398775 100644 --- a/.agents/skills/nexent-spec-coding/SKILL.md +++ b/.agents/skills/nexent-spec-coding/SKILL.md @@ -1,73 +1,67 @@ --- name: nexent-spec-coding -description: Run document-driven SPEC development for Nexent features, fixes, refactors, APIs, UI, internal logic, and model or Agent runtime changes. Use with the Git-managed nexent code repository and its separate sibling nexent-doc repository when work requires reviewed requirements, test-first implementation, functional verification, and acceptance evidence. +description: Run the Nexent requirement or bug lifecycle from SPEC analysis through feature catalog and D1-D5 case design, product implementation, fixed test implementation, local verification, and delivery evidence. Use for features, fixes, refactors, APIs, UI, and model or Agent runtime changes. --- # Nexent SPEC Coding -Develop Nexent from an approved SPEC and produce evidence for every acceptance criterion. +Develop Nexent from an evidence-backed SPEC while keeping product behavior, formal D1-D5 test assets, implementation, and verification synchronized. ## Repository boundaries -- Treat `nexent/` as the Git-managed code repository and its sibling `nexent-doc/` as the document repository. -- Never run Git commands in the document repository or move SPEC documents into the code repository. -- Search and reuse existing feature specifications before creating a new one. Name each new SPEC or change directory from the canonical registry at `/docs/Developing/spec-module-abbreviations.md`: `--<2-to-5-feature-words>`, or `-<2-to-5-feature-words>` when level 2 is omitted. Keep `proposal.md`, `design.md`, `task.md`, and `delta-spec.md` as fixed artifact names inside that directory. -- Preserve unrelated changes. Record the initial code branch/status, applicable repository instructions, build configuration, and test layout. +- Treat `nexent/` as the Git-managed product repository and its sibling `nexent-doc/` as the document repository. +- Never run Git commands in the document repository or move SPEC documents into the product repository. +- Preserve unrelated changes. Record the initial branch/status, applicable repository instructions, build configuration, and test layout. +- Keep the existing tests under `test/backend`, `test/sdk`, and `test/ext_components` as Legacy UT. Do not map them into the formal D1-D5 manifest. ## Read the right references -Select the document mode with [spec-maintenance-guide.md](references/spec-maintenance-guide.md): create a requirement-scoped SPEC for new features and refactors; update a usable baseline or add a delta for a bug fix; reconstruct the owning feature from code and tests when no usable baseline exists. Omit uncertain peripheral detail, but resolve uncertainty that affects the change. - | Work | Required reference | | --- | --- | -| Name a new SPEC or change set | Canonical module registry at `/docs/Developing/spec-module-abbreviations.md` | +| Choose the SPEC maintenance mode | [spec-maintenance-guide.md](references/spec-maintenance-guide.md) | | Create or revise `proposal.md` | [proposal-template.md](references/proposal-template.md) | -| Create or revise `design.md`; design D1 cases | [design-template.md](references/design-template.md) and [test-design-guide.md](references/test-design-guide.md) | +| Create or revise `design.md`; design formal D1-D5 cases | [design-template.md](references/design-template.md) and [test-design-guide.md](references/test-design-guide.md) | | Create or revise `task.md` | [task-template.md](references/task-template.md) | | Plan, execute, or close verification | [verification-guide.md](references/verification-guide.md) | -`proposal.md` owns current-change AC definitions, `design.md` owns design rationale and test design, and `task.md` owns the single traceability/evidence table. A delta owns proposed requirement text when delta mode is selected. Preserve established baseline requirements, historical ACs, and approved decisions. Follow each template's required, conditional, and optional sections; remove unused optional sections and placeholders. - -This workflow adapts [OpenSpec](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/schema.yaml) to Nexent. It uses singular `task.md`, local document paths, and manual baseline/delta integration; it does not claim OpenSpec CLI compatibility. +Use `nexent-test-assets` for the concrete feature-catalog, case, automation, manifest, schema, and Excel formats. `proposal.md` owns the current-change acceptance criteria, `design.md` owns design rationale and test strategy, and `task.md` owns the change-level traceability and evidence record. ## Gated workflow ### 1. Analyze -Search existing SPECs and choose the document mode before writing. For a new SPEC or change set, resolve its owning level-1 module and use a registered level-2 module when the scope has one stable owner; then choose a 2-to-5-word feature description. Inspect the relevant code, callers, interfaces, persistence, services, UI, tests, configuration, and runtime paths. Cite concrete paths, symbols, APIs, schemas, and configuration names. Do not implement production code during analysis. +Search existing SPECs and choose the maintenance mode. Inspect the relevant code, callers, interfaces, persistence, services, UI, formal test assets, configuration, and runtime paths. Cite concrete paths, symbols, APIs, schemas, and configuration names. Do not implement production code during analysis. -### 2. Write and review the SPEC +### 2. Define behavior and formal D1-D5 cases -Create or update `proposal.md` and `design.md`, then `task.md`. Define stable requirement feature IDs and observable ACs with verification layers, evidence, and exact pass conditions. For an undocumented bug, reconstruct the owning feature's main behavior and design while keeping implementation scope limited to the requested fix. +Create or update `proposal.md`, `design.md`, and `task.md`. Update the product feature catalog and define observable acceptance criteria. Design every applicable D1-D5 case before product implementation. Each case must have a stable Case ID, owning Feature ID, priority, precise preconditions, steps, expected results, forbidden side effects, and the stage-specific fields required by the repository schemas. -In `design.md`, map every in-scope Scenario and relevant unit-level design contract to stable D1 case IDs at the lowest proving layer: `FE-COMP`, `BE-UT`, or `SDK-UT`. Define implementable preconditions, steps, assertions, forbidden side effects, priority, fixtures/mocks, and later verification exclusions. Build the `task.md` traceability table from requirements, ACs, design sections, D1 cases, later verification, and planned evidence. +Create a requirement change record under `test/changes/requirements/` or a lightweight bug record under `test/changes/bugs/`. Run the formal asset validators and regenerate the Excel view. Do not invent final script paths, selectors, implementation hashes, or passing results during design. -Review the documents, any delta, baseline references, and actual code together. The design gate fails if a Scenario lacks a D1 case, a case lacks implementable assertions, a case crosses its declared boundary, or the matrix is stale. Resolve questions that change scope, behavior, design, tests, or tasks. Production implementation starts only after explicit approval; explicit autonomous authorization may be recorded with a self-review in `task.md`. +The design gate fails when an in-scope behavior lacks an applicable case or justified exclusion, a case lacks executable assertions, stage boundaries are violated, affected Feature/Case declarations are stale, or schema and traceability validation fail. Resolve material behavior conflicts before implementation. No separate manual test-case approval is required by this workflow. -### 3. Write D1 tests first +### 3. Implement the product behavior -Implement the designed tests before the corresponding production behavior and bind each test to its case ID. Use fixed fixtures and mocks only for isolation or controlled faults. For a bug, preserve a focused reproduction that fails before the fix when feasible. Run the smallest relevant group and confirm the expected failure before production edits when a meaningful red step is possible; otherwise record the concrete reason in `task.md`. +Implement the minimum change needed to satisfy the defined behavior, following existing architecture and contracts. If implementation reveals that a requirement, product rule, Scenario, or test contract is wrong, update and validate the formal design assets before continuing. Do not silently weaken a case to fit the implementation. -Missing, unimplemented, skipped, or expected-failure cases do not pass. Keep product, test, and environment failures distinguishable. +### 4. Implement fixed tests and the manifest -### 4. Implement and pass D1 +After product implementation, implement the affected formal D1-D5 scripts under `test/automation/d1` through `test/automation/d5`. Bind each automated script or test item to its Case ID and incrementally update only the affected entries in `test/manifests/d1-d5.yaml`. Then run full manifest and traceability validation. -Implement the minimum approved production change needed to satisfy the cases, following existing architecture and contracts. Each feature task names its AC and case IDs and completes only after all associated assertions, including forbidden side effects, pass. +For a confirmed bug, a focused failing reproduction may be implemented before the product fix when that is the clearest way to preserve the regression. Existing formal cases should be strengthened instead of duplicated when they already own the behavior. -Run the smallest group while developing, then the complete affected D1 group. Do not weaken assertions to pass. When implementation changes a requirement, Scenario, contract, boundary, or design, update and re-review the documents and matrix before continuing. +Legacy UT maintenance remains separate and uses `nexent-python-tests` only when the change breaks or intentionally updates that suite. -### 5. Verify changed behavior +### 5. Verify affected behavior -After required D1 cases pass, execute every later verification layer triggered by the change. Use a real browser for frontend interaction, a running service and real requests for HTTP contracts, stable-interface tests for internal behavior, and actual Nexent paths plus configured services and Langfuse evidence for model, embedding, or Agent runtime behavior. +Run the affected formal D1-D5 cases selected from the change record. Use fixed Playwright scripts for D4. Keep Mock and Real Smoke results distinct when both profiles apply. Missing, unimplemented, skipped, or expected-failure required cases do not pass. Keep product, test, environment, and external-provider failures distinguishable. -Source review, mocks, builds, and D1 results do not replace required functional verification. Missing required credentials, infrastructure, or evidence makes the affected AC `BLOCKED`. Follow the verification guide for secret handling, proof details, and statuses. +Record sanitized commands, results, evidence paths, and unresolved blockers in `task.md`. Do not claim API, browser, model, Agent, security, reliability, performance, or deployment acceptance from a lower layer. ### 6. Close out -Update `task.md` with actual code paths, case results, commands/scenarios, artifacts, trace references, and approved deviations. Use `PASS` only after all required proofs pass; use `PENDING`, `FAIL`, `BLOCKED`, and justified layer-level `N/A` as defined by the verification guide. - -Confirm the SPEC, delta, design, tasks, tests, and implementation agree. Integrate approved and verified deltas into the baseline, preserve delta history, and resolve concurrent baseline changes. Report changed files, AC results, executed checks, remaining risks, and unverified items. Completion requires every current required AC to be `PASS`. +Run the unified formal-asset validator, deterministic Excel check, affected tests, and relevant product checks. Confirm SPEC, feature catalog, cases, change record, scripts, manifest, implementation, and evidence agree. Report changed files, Case results, remaining risks, and unverified items. Formal completion requires every current required acceptance criterion to pass. ## Stop conditions -Pause the affected work when approval is missing, a material requirement conflict remains unresolved, required access or evidence is unavailable, verification would mutate a system outside the authorized scope, or an AC cannot be proved with the available surface. Explain the affected ACs and missing input, and continue safe independent work when the blocker is local. +Pause the affected work when a material behavior conflict remains unresolved, required access or evidence is unavailable, verification would mutate a system outside the authorized scope, or an acceptance criterion cannot be proved with the available surface. Continue safe independent work when the blocker is local. diff --git a/.agents/skills/nexent-spec-coding/references/design-template.md b/.agents/skills/nexent-spec-coding/references/design-template.md index e5ef6befe3..8c7c53f319 100644 --- a/.agents/skills/nexent-spec-coding/references/design-template.md +++ b/.agents/skills/nexent-spec-coding/references/design-template.md @@ -2,69 +2,52 @@ ## Usage guide -Create or update `design.md` according to [SPEC maintenance guidance](spec-maintenance-guide.md). Keep this fixed filename inside a SPEC directory whose name uses a registered level-1 module, an optional registered level-2 module, and a 2-to-5-word feature description. This document is required for Nexent; a small fix with an adequate baseline may use short paragraphs. For an undocumented bug, reconstruct the overall owning-feature design, including unaffected main paths. Omit most unconfirmed peripheral details, not evidence-backed core structure. Required sections must remain. Conditional sections become mandatory when triggered. Delete unused optional/conditional sections, placeholders and this guide from the generated document. - -| Section | Requirement | When / what to write | -| --- | --- | --- | -| Context | Required | Relevant code and constraints; whole-feature reconstruction when no usable baseline exists | -| Decisions | Required | Chosen approach and reasons; real alternatives when a meaningful choice exists | -| Proposed Design | Required | Target flow and component responsibilities | -| D1 Test Design | Required | Scenario-level FE-COMP, BE-UT and SDK-UT cases before implementation | -| Later-layer Verification Strategy | Required | API, browser, real-model, Agent-runtime and other applicable proof surfaces | -| Risks / Trade-offs | Required | Known risks and mitigations, or a brief no-known-material-risk statement | -| Interface / Data Changes | Conditional | Changed interfaces, schemas or persistent state | -| Migration / Rollback | Conditional | Data/config migration, deployment transition or compatibility work | -| Model / Agent Verification | Conditional | Model inference, embedding, Agent runtime or tool-flow changes | -| Detailed File List / Diagrams | Optional | Extra detail that clarifies implementation | -| Open Questions | Optional | Only questions that can safely be deferred | - -Reference `proposal.md` for motivation, scope and AC definitions. Follow [D1 test design guidance](test-design-guide.md) for the mandatory case matrices and test-first gate. Keep the single change-level acceptance traceability table in `task.md`. Use this template's guidance rather than reproducing every example. The approach follows [OpenSpec's design guidance](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/schema.yaml), with Nexent-specific mandatory design and verification sections. +Create or update `design.md` according to [SPEC maintenance guidance](spec-maintenance-guide.md). Reference `proposal.md` for motivation, scope and acceptance criteria. Define formal D1-D5 case contracts before product implementation; implement fixed automation after stable interfaces exist. Use [D1-D5 test design guidance](test-design-guide.md) and the `nexent-test-assets` skill. Structured test assets, not this prose or Excel, are the executable source of truth. # Design — ## Context - + ## Decisions ### D-001 — - + ## Proposed Design - + -## D1 Test Design +## D1-D5 Test Design - + -## Later-layer Verification Strategy +| Feature / rule | D1 | D2 | D3 | D4 | D5 | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| | | | | | | | - +## Test Implementation Strategy + + ## Risks / Trade-offs - + ## Interface / Data Changes - + ## Migration / Rollback - + ## Model / Agent Verification - - -## Detailed File List / Diagrams - - + ## Open Questions - - + diff --git a/.agents/skills/nexent-spec-coding/references/proposal-template.md b/.agents/skills/nexent-spec-coding/references/proposal-template.md index 865d7cef2c..81972e5ae2 100644 --- a/.agents/skills/nexent-spec-coding/references/proposal-template.md +++ b/.agents/skills/nexent-spec-coding/references/proposal-template.md @@ -31,7 +31,7 @@ This is a Nexent adaptation of [OpenSpec's spec-driven schema](https://github.co ## Capabilities and Scenarios - + ## Baseline Inventory diff --git a/.agents/skills/nexent-spec-coding/references/task-template.md b/.agents/skills/nexent-spec-coding/references/task-template.md index d311982171..d72d016fe3 100644 --- a/.agents/skills/nexent-spec-coding/references/task-template.md +++ b/.agents/skills/nexent-spec-coding/references/task-template.md @@ -2,68 +2,52 @@ ## Usage guide -Create or update `task.md` (singular) according to [SPEC maintenance guidance](spec-maintenance-guide.md). Keep this fixed filename inside a SPEC directory whose name uses a registered level-1 module, an optional registered level-2 module, and a 2-to-5-word feature description. Keep prior task/evidence history when updating an existing SPEC; identify the current change and its ACs. Required sections must remain. Conditional tasks are mandatory when their trigger applies; omit irrelevant tasks rather than checking them off. Remove this guide and replace placeholders in the generated document. - -| Section | Requirement | When / what to write | -| --- | --- | --- | -| D1 Tests | Required | Implement designed case IDs before corresponding production behavior | -| Implementation | Required | Dependency-ordered production tasks mapped to D1 cases and ACs | -| SPEC Maintenance Tasks | Conditional | Missing-baseline reconstruction or delta integration | -| Later-layer Verification | Required | Applicable cross-task and system verification after D1 passes | -| Acceptance Traceability | Required | One row per AC linking design, code, tests, evidence and result | -| Completion Check | Required | All required ACs pass and documents reflect delivered behavior | -| Deployment / Migration | Conditional | Design requires rollout, migration or compatibility work | -| Execution Notes | Optional | Useful blockers, deviations or execution context | - -Within D1 Tests, include every case ID designed in design.md and the smallest/affected-group commands. Feature implementation follows its corresponding tests. Within Later-layer Verification, API checks are required for changed HTTP behavior, browser checks for frontend interactions, and real-service/runtime checks for model or Agent changes. See verification-guide.md; irrelevant layers require a reason for N/A. - -Use numbered groups and `- [ ] X.Y` checkboxes. Each task must state how completion is verified. Reference relevant AC IDs; setup tasks can explain their supporting role. Include separate verification tasks for broader integration/system checks. Test creation remains explicit work; do not duplicate the same check merely to fill a group. Mark a checkbox only after its completion condition is met. - -Before finalizing tasks, resolve questions that change scope, design or task breakdown. Keep design rationale in `design.md` and AC definitions in `proposal.md`. This adapts [OpenSpec's task guidance](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/schema.yaml); OpenSpec itself uses `tasks.md`. +Create or update `task.md` according to [SPEC maintenance guidance](spec-maintenance-guide.md). The dependency order is formal test design, product implementation, fixed test implementation and manifest binding, local verification, then source/view closeout. Use numbered groups and checkboxes. Do not merge Legacy UT obligations into formal D1-D5 coverage. # Tasks — -## 1. SPEC Maintenance Tasks - - +## 1. Formal Test Asset Design -## 2. D1 Tests +- [ ] 1.1 Update affected feature contracts and business rules. [AC-001] +- [ ] 1.2 Add or modify required D1-D5 structured cases with explicit assertions and exclusions. [AC-001] +- [ ] 1.3 Run the design-phase unified validator and regenerate Excel before product implementation. [AC-001] -- [ ] 2.1 [UT-BE-area-001] [AC-001] -- [ ] 2.2 [UT-BE-area-001] [AC-001] +## 2. Product Implementation -## 3. Implementation +- [ ] 2.1 Implement approved product behavior without changing the case contract to match implementation quirks. [AC-001] -- [ ] 3.1 [UT-BE-area-001] [AC-001] +## 3. Fixed Test Implementation -## 4. Later-layer Verification +- [ ] 3.1 Implement affected D1-D5 scripts after stable interfaces exist; bind case IDs in test metadata. [AC-001] +- [ ] 3.2 Add or update manifest entries and hashes for only affected cases. [AC-001] +- [ ] 3.3 For a bug fix, retain a focused regression reproduction; an earlier reproduction script is allowed when it helps prove the defect. [AC-001] -- [ ] 4.1 [AC-001] -- [ ] 4.2 [AC-001] +## 4. Verification - +- [ ] 4.1 Run the smallest affected case selection and record exact results. [AC-001] +- [ ] 4.2 Run all applicable affected D1-D5 groups and required mock/real-smoke profiles. [AC-001] +- [ ] 4.3 Regenerate the Excel view and run the unified test-asset validator. [AC-001] ## 5. Acceptance Traceability -| AC | Design | Code areas | D1 case IDs / later scenarios | Evidence | Result | +| AC | Design | Code areas | Formal case IDs | Evidence | Result | | --- | --- | --- | --- | --- | --- | -| AC-001 |
| | | | PENDING | +| AC-001 |
| | | | PENDING | -Use PENDING before execution, PASS when all required proofs pass, FAIL for a failed assertion, and BLOCKED for missing prerequisites or required evidence. N/A applies only to an irrelevant verification layer with a reason; it does not pass or remove a required AC. Keep failed evidence until understood and link rerun results. Every current-change AC in proposal.md must appear here and reference its baseline/delta requirement. Preserve older AC rows and evidence as history with their original scope/status; do not reset or claim them as rerun. Baseline observations outside current acceptance remain explicitly unverified where appropriate. +Use `PENDING`, `PASS`, `FAIL` or `BLOCKED`. `N/A` applies only to an irrelevant verification layer with a recorded reason. Preserve failed evidence and link reruns. ## 6. Completion Check -- [ ] 6.1 Verify every in-scope Scenario has implemented and executed D1 case IDs; no required case is missing, skipped, xfailed or failing, and no P0/P1 case is skipped or expected failure. -- [ ] 6.2 Verify every required AC is PASS with linked D1 and later-layer evidence. -- [ ] 6.3 Verify required tests and runtime checks pass and report exact commands/scenarios and counts. -- [ ] 6.4 Confirm the SPEC or change directory follows the canonical module naming rule, then review changed files for scope and verify proposal.md, design.md and task.md match the delivered implementation. -- [ ] 6.5 Record approval or authorized self-review and any approved deviations; report remaining risks and unverified checks explicitly. -- [ ] 6.6 Verify the selected SPEC maintenance mode is complete; for reconstruction review whole-feature coverage and evidence, and for deltas confirm baseline/design integration and retained delta history. +- [ ] 6.1 Every affected requirement and business rule has validated structured cases at every required stage. +- [ ] 6.2 Every automated affected case has a valid manifest binding and executable fixed script. +- [ ] 6.3 No required P0/P1 case is missing, skipped, expected failure or unimplemented. +- [ ] 6.4 The generated Excel view matches structured assets and was not manually maintained. +- [ ] 6.5 Proposal, design, tasks, product behavior and formal assets agree; remaining risks are explicit. ## 7. Deployment / Migration - + ## 8. Execution Notes - + diff --git a/.agents/skills/nexent-spec-coding/references/test-design-guide.md b/.agents/skills/nexent-spec-coding/references/test-design-guide.md index c987f207ac..f9b176ffbb 100644 --- a/.agents/skills/nexent-spec-coding/references/test-design-guide.md +++ b/.agents/skills/nexent-spec-coding/references/test-design-guide.md @@ -1,71 +1,48 @@ -# D1 Test Design Guide +# D1-D5 Test Design Guide -Use this guide while writing or revising `design.md`. It defines the pre-implementation test design for in-scope requirements and scenarios. D1 covers isolated unit and component behavior. API, database full-stack, Playwright, real-model, Agent-runtime, security, reliability, and other system checks remain separate verification layers. +Use this guide while writing or revising `design.md`. Formal Nexent acceptance assets are designed from requirements before product implementation and stored as structured feature and case files. Fixed automation scripts and manifest entries are implemented after product code exposes stable interfaces, except that a focused bug reproduction may be written earlier. ## Required design content -Build a coverage inventory from every in-scope requirement and scenario, including relevant design contracts such as SDK schemas, adapters, serialization, stable event identity, state transitions, and validation rules. Give each requirement a stable feature ID such as `[AUTH-001]`. Every in-scope scenario maps to at least one independently verifiable case ID. +For every in-scope requirement or bug contract: -Include these items in `design.md`: +- assign or preserve a stable feature ID and observable business rules; +- select every required proving stage from D1 through D5; +- define independently verifiable cases with explicit preconditions, data, steps, expected results and forbidden side effects; +- identify mock and optional real-smoke profiles without embedding credentials or developer-local assets; +- record exclusions with a concrete reason rather than silently omitting a normally applicable stage. -- D1 scope and explicit later-layer exclusions; -- scenario-to-case traceability rules; -- separate `FE-COMP`, `BE-UT`, and `SDK-UT` case tables for applicable cases; -- the test-first implementation and acceptance flow; -- recommended test-file grouping without requiring final paths before implementation. +Write formal assets through `nexent-test-assets`. Structured YAML/JSON files are the source of truth. Excel is a deterministic generated view and must not be edited as the source. -Design review fails when an in-scope scenario has no case, a case lacks observable assertions, a case crosses the D1 boundary, or changed requirements leave the matrix stale. Create `task.md` only after human review confirms the matrix is complete and implementable. +## Choose the proving stage -## Choose the lowest proving layer - -| Layer | Use for | Boundary | +| Stage | Primary responsibility | Typical type | | --- | --- | --- | -| `BE-UT` | Python functions, service logic, validation and state machines | Do not start the full system | -| `SDK-UT` | SDK schemas, adapters, serialization and event metadata | Do not call real external services | -| `FE-COMP` | React components, hooks, reducers, stores and forms | Do not start a real browser | - -Assign each case to the lowest layer that proves its behavior. Record HTTP/database full-stack checks, browser journeys, real AI runtime checks, and other later-layer verification separately. D1 success cannot establish those results. - -## Case table schema - -Use one row per independently verifiable behavior. Parameterize only when inputs share setup, execution path, and assertions. - -| Column | Required content | -| --- | --- | -| Case ID | Stable ID: `UT-FE--NNN`, `UT-BE--NNN`, or `UT-SDK--NNN` | -| Feature ID | Stable ID copied from the owning requirement | -| Level-1 module | Registered product capability from the canonical SPEC module registry | -| Level-2 module | Registered level-2 module, or `-` when the SPEC omits level 2 | -| Responsibility | Smallest user-visible or runtime responsibility under test | -| Title | Concrete behavior and normal/error/boundary path when useful | -| Layer | Exactly one of `FE-COMP`, `BE-UT`, or `SDK-UT` | -| Priority | `P0`, `P1`, or `P2`, based on business and regression risk | -| Scenario-based | `Yes` for interaction-oriented component cases; otherwise `No` unless the project defines another meaning | -| Journey | Existing E2E journey ID, or `-`; do not invent one | -| Preconditions | Minimal fixture state, flags, permissions, versions and unit setup | -| Local assets/config | Fixed fixtures or local assets; `None` when absent; never credentials | -| Steps | Numbered actions naming inputs, unit boundary, injected dependencies, user events and controlled faults | -| Expected results/assertions | Exact outputs, state transitions, dependency arguments/counts, errors and forbidden side effects | -| External dependency policy | Normally `NO_EXTERNAL`: local code, components, fixed fixtures and mocks only; no full system, browser or real model | +| D1 | Isolated unit or component behavior | `BE-UT`, `SDK-UT`, `FE-COMP` | +| D2 | API and protocol contracts | `API-IT`, `CONTRACT` | +| D3 | Integrated runtime and provider behavior | `AGENT-IT`, `INTEGRATION` | +| D4 | Complete browser user journeys | `E2E` | +| D5 | Security, reliability, performance and deployment risk | `SECURITY`, `RELIABILITY`, `PERFORMANCE`, `DEPLOYMENT` | -Do not require operational result columns such as daily CI stage, blocking policy, failure artifact, final code path, source URL, or tags during design unless the project explicitly asks for them. +Use the lowest stage that proves a behavior, then add higher stages only when their boundary adds necessary evidence. Mixed features commonly need several stages. D1 success never establishes D2-D5 results. ## Case quality rules -- A title covers one behavior. Preconditions stay minimal. Steps identify the actual unit and concrete state or input. -- Assertions are implementable without guessing. Add forbidden-side-effect assertions where relevant, including no model call, persistence write, cross-session contamination, stale-state overwrite, downstream execution, or sensitive-data disclosure. -- Permission cases assert rejection and non-disclosure. Concurrency or isolation cases use distinct contexts and assert both directions of non-contamination. Component interaction cases use user events and assert accessible state where relevant. -- Fixed fixtures and mocks provide isolation or controlled fault injection. Keep external services out of D1. Do not make unsupported future behavior pass by expectation; keep it out of scope until the requirement changes. -- Row count is not semantic proof. Reviewers confirm that each case's steps and assertions actually prove its owning scenario. +- One case proves one concrete behavior or coherent journey outcome. +- Assertions derive from the requirement, not current implementation quirks. +- Preconditions and test data name logical assets or profiles, never absolute paths, credentials, generated runtime IDs or SQL file locations. +- Include forbidden-side-effect checks when relevant: no unintended persistence, provider call, cross-tenant leakage, stale-state overwrite, downstream execution or sensitive-data disclosure. +- D2 defines status, schema, error and compatibility contracts. D3 identifies integration boundaries, observations and cleanup. D4 uses a full actor journey and observable business result. D5 names the risk, conditions, metrics, thresholds and recovery expectation. +- A2A is eligible at every applicable stage, including D4 journeys. OAuth/CAS remains skipped by policy until that policy changes. +- Missing, skipped, expected-failure or unimplemented required cases are not passing cases. -## Test-first and acceptance flow +## Lifecycle -1. Before implementation, enumerate in-scope scenarios and design contracts, assign feature and case IDs, record later-layer exclusions, and review the matrix. -2. Add or update D1 tests before the corresponding production behavior. Bind each repository test to its case ID in its name, marker, docstring, or adjacent metadata. For a bug, retain a focused reproduction that fails before the fix when feasible. -3. Run the smallest relevant case group. Confirm new or corrected behavior fails for the expected reason before changing production logic when the repository and change permit a meaningful red step. If scaffolding, generated artifacts, or another concrete constraint makes this impossible, record the reason in `task.md`; the designed case is still required. -4. Implement the minimum approved production change needed to satisfy the case, rerun the smallest group, then run the complete affected D1 group. -5. A case passes only when every listed assertion and forbidden-side-effect check passes. Missing, unimplemented, skipped, or expected-failure cases do not pass unless the approved SPEC scope changes. Keep product, test, and environment failures distinct. -6. After D1 passes, run the separate verification layers required by the changed behavior. Do not infer API, browser, real-model, Agent-runtime, security, reliability, or full-system acceptance from D1. -7. Accept the change only when all in-scope scenarios have traceable cases, all required cases are implemented and executed, P0/P1 cases are neither skipped nor expected failures, later-layer requirements pass, and the SPEC, design, tasks, tests, and implementation agree. +1. During requirement or bug design, update the feature inventory and formal D1-D5 case contracts. +2. Run `python test/tools/validate_test_assets.py --phase design --generate-excel` before product implementation begins. +3. Implement product behavior. +4. Implement fixed scripts and manifest entries after interfaces stabilize. Bind every automated case ID to its implementation. +5. Run affected cases locally, then the applicable broader stage groups. +6. Run `python test/tools/validate_test_assets.py --phase implementation --generate-excel` before closeout. -If implementation changes a requirement, scenario, contract, or boundary, revise and review the matrix before continuing. Do not silently invent missing cases during implementation. +Legacy implementation-oriented tests remain separate. They may continue to run during transition, but they are not formal D1-D5 assets and cannot satisfy formal case or manifest coverage. diff --git a/.agents/skills/nexent-spec-coding/references/verification-guide.md b/.agents/skills/nexent-spec-coding/references/verification-guide.md index aa9dc8a702..71e680641c 100644 --- a/.agents/skills/nexent-spec-coding/references/verification-guide.md +++ b/.agents/skills/nexent-spec-coding/references/verification-guide.md @@ -1,81 +1,45 @@ # Acceptance Verification Guide -## Usage and required records +## Required records -Read this guide when writing design.md's later-layer verification strategy and task.md's verification tasks, before verification, and at closeout. Use test-design-guide.md for D1 case design and the test-first gate. AC definitions and pass conditions belong in proposal.md. Record the strategies in design.md and maintain results/evidence only in task.md's Acceptance Traceability table. Evidence files may be separate; link them from that table. +Before implementation, record affected feature IDs, business rules, acceptance criteria and required D1-D5 case contracts. During execution, record exact commands, environment profile, version, observed results and evidence paths. Keep results in `task.md` traceability and formal run artifacts; do not write execution state back into the case contract. -| Content | Requirement | -| --- | --- | -| AC coverage, applicable proof surfaces, pass assertions and planned evidence | Required before implementation | -| One traceability row per current-change AC with baseline/delta requirement, design, code, D1 case IDs, later scenarios, evidence and result | Required throughout execution | -| Commands/scenarios, environment, data, time, version and observed result | Required for executed verification | -| API/browser/model/Agent/embedding checks | Conditional on the changed behavior below | -| Migration/rollback checks | Conditional on the approved design | -| Screenshots, videos, extended logs or extra diagrams | Optional unless an AC explicitly requires them | - -An optional artifact cannot replace required assertions. A triggered proof surface is mandatory. Explain why omitted layers are irrelevant in design.md or the traceability row; never omit required verification because it is inconvenient or unavailable. Complete and execute the required D1 cases before later-layer verification, except for diagnostic runs. - -## D1 execution gate - -Implement designed D1 tests before their corresponding production behavior. Preserve stable case IDs in repository test metadata. Run the smallest relevant group to obtain a meaningful expected failure when feasible, implement the minimum production change, rerun that group, then run the complete affected D1 group. A passing coverage percentage cannot replace case assertions. - -A D1 case passes only when every listed expected result and forbidden-side-effect assertion passes. Missing, unimplemented, skipped or expected-failure cases remain incomplete unless the approved SPEC changes. No P0/P1 case may be skipped or expected failure at acceptance. Keep product, test and environment failures distinguishable. If the implementation changes a requirement, Scenario, contract or boundary, revise and review design.md's matrix before continuing. - -D1 proves isolated unit/component behavior only. Its result cannot be reused as proof for API, browser, real-model, Agent-runtime, security, reliability, migration or full-system acceptance. - -## SPEC mode and baseline verification - -Use [SPEC maintenance guidance](spec-maintenance-guide.md) to identify new work, refactor, direct bug-fix update, delta or baseline reconstruction. Before implementation, check baseline references, current-change ACs and any complete delta requirement blocks. Refactors verify preserved behavior; fixes verify the defect's correction and affected regression paths. When feasible, demonstrate the reproduction fails before the fix and passes after it; otherwise record why before/after execution is unavailable and retain a regression assertion. +## Verification rules -For a missing-baseline bug, review reconstructed whole-feature coverage against concrete code/tests, including unchanged main paths and overall design. Distinguish observed implementation, intended behavior and unverified inference. This documentation review is required but does not substitute for runtime acceptance of the fix. Do not require unrelated feature changes or claim all reconstructed behavior was executed. Record the current acceptance scope and keep historical ACs/evidence intact; any baseline AC selected for this change receives all required proof. - -For delta work, verify operation targets exist, MODIFIED blocks preserve unaffected scenarios, and current ACs reference the proposed requirements. At closeout, check approved/verified requirements were integrated into the baseline and affected design, with integration status and preserved delta history in task.md. Missing required integration or unresolved conflicts prevents completion. +- Run schema and traceability validation before product implementation. +- After product implementation, add or update fixed scripts and manifest entries for affected automated cases. +- A case passes only when all expected results and forbidden-side-effect assertions pass. +- Missing, unimplemented, skipped or expected-failure required cases remain incomplete. Required P0/P1 cases cannot be accepted that way. +- Keep product, environment, provider, asset and test-implementation failures distinguishable. +- If implementation reveals a requirement change, revise the requirement and structured case contract explicitly; never silently weaken assertions. +- Legacy UT may supply transition evidence but cannot satisfy formal D1-D5 manifest coverage. ## Choose the proof surface -| Changed behavior | Required primary proof | +| Stage | Required proof surface | | --- | --- | -| Pure business or internal logic | Unit/integration test at a stable interface | -| Backend HTTP contract | Running service and real curl/wget requests | -| Frontend interaction | Playwright in a real browser | -| Model inference | Real configured model call through Nexent and Langfuse generation/span | -| Agent runtime or tool flow | Real Agent run and step-level Langfuse trace | -| Embedding behavior | Real embedding call through Nexent, downstream assertion and corresponding Langfuse evidence | -| Data/config migration or deployment transition | Approved migration/compatibility checks and rollback validation where applicable | - -Use all applicable surfaces for mixed changes. Add lower-level tests as needed. Source inspection, mocks or builds do not replace primary proof. Required D1 cases pass before functional verification, except diagnostic execution. - -## Evidence and status rules - -- Tie each artifact to AC IDs. Record exact commands or reproducible scenarios, environment shape, test data, timestamp and relevant version/commit; capture expected versus observed results and test counts where applicable. -- Redact keys, authorization headers, cookies, tokens, private prompts and sensitive user data. Keep enough sanitized detail to reproduce the check without storing secrets. -- Prefer text results and artifact paths/trace IDs. Keep failed evidence until understood and attach rerun results. Distinguish expected warnings from failures. -- Use PENDING before execution, FAIL for failed assertions and BLOCKED for missing prerequisites or mandatory evidence. PASS requires all mandatory proofs for that AC to pass. A completed implementation checkbox does not imply AC acceptance. -- N/A applies only to an irrelevant layer with a recorded reason, never to an entire required AC. Missing credentials, services or traces are BLOCKED, not N/A. Report mixed FAIL/BLOCKED checks explicitly even if the row has one summary result. -- Mark task checkboxes only after their completion checks pass. Completion requires every required AC to be PASS; report unverified criteria and remaining risks explicitly. - -## Browser verification — conditional - -For changed frontend interactions, start the application using discovered repository commands and exercise the actual user journey. Assert visible state, relevant requests/responses and persistence. Cover loading, empty/error, disabled, permission and refresh states where relevant. Inspect browser console errors. Screenshots, video and reports are optional unless required by an AC or needed to demonstrate the result. - -## API verification — conditional +| D1 | Isolated function, SDK or component execution | +| D2 | Running API/protocol boundary and contract assertions | +| D3 | Integrated runtime/provider path with observable state and cleanup | +| D4 | Playwright browser journey and final business outcome | +| D5 | Risk-specific security, reliability, performance or deployment check | -For changed HTTP behavior, call real running endpoints. Verify status codes, response schema/payload and relevant side effects. Cover authentication/permission, headers, idempotency and errors where applicable. Query a read surface or logs to confirm side effects. Use disposable or explicitly authorized data. Record sanitized requests, responses and assertions. +Use all applicable stages for mixed changes. Mocks provide deterministic primary coverage. Optional real-smoke profiles prove selected real-provider integrations and remain separately identified. -## Internal verification — conditional +## Evidence and status -For internal-only changes, exercise the nearest stable interface with unit/integration tests. Cover regressions, boundaries and failure paths named by ACs. Mock only unrelated external boundaries; do not mock away the behavior being accepted. API/browser checks may be N/A with an explanation. +- Tie each artifact to acceptance criteria and formal case IDs. +- Redact keys, authorization headers, cookies, tokens, private prompts and sensitive user data. +- Prefer textual results and local artifact paths. D4 retains screenshots/traces only for failures unless a case requires otherwise. +- Use `PENDING`, `PASS`, `FAIL` and `BLOCKED`. A completed implementation task never implies acceptance. +- A missing prerequisite is `BLOCKED`, not `N/A`. `N/A` requires an irrelevance reason. -## Model, embedding and Agent verification — conditional +## Model, Agent and external-provider checks -1. Check applicable variable presence without displaying values. LLM uses MODEL_URL, MODEL_API_KEY, MODEL_NAME. Embedding uses EMBED_MODEL_URL, EMBED_MODEL_API_KEY, EMBED_MODEL_NAME. -2. Start the actual Nexent runtime path and execute controlled, reproducible scenarios mapped to ACs. Real-model output need not be byte-identical; assert the specified behavioral contract. -3. Locate the matching Langfuse trace using correlation IDs, session, metadata or time. Inspect inputs/assembled context, configuration, generations, tool arguments/results, intermediate states, parsing, errors and retries as relevant. Include latency/token usage when acceptance depends on them. -4. Compare relevant steps and final output with the AC. For embedding, assert the downstream use of the vectors as well as the real call. -5. Record sanitized trace IDs/links, step-level observations and pass/fail evidence in task.md. Missing, incomplete or mismatched mandatory traces block acceptance. Never count a successful HTTP response alone as proof of correct Agent behavior. +Use logical profiles instead of machine-specific values. Verify the actual Nexent runtime path, controlled input, downstream behavior, tool/provider interaction and final observable result. Record sanitized correlation or trace IDs where required. A successful HTTP status alone does not prove Agent behavior. -## Traceability record +A2A can be proved at D2 protocol, D3 integration, D4 full journey and D5 fault/security layers as applicable. OAuth/CAS remains skipped by policy until that scope changes. -Use the table from task-template.md; do not create a competing completion matrix. Every current-change AC must link its baseline/delta requirement, design, planned/actual code, D1 case IDs, later-layer scenarios, evidence and result. Preserve prior AC records separately with their original scope and results. In the case/scenario and evidence cells, identify applicable layers and their results, plus N/A reasons for omitted layers. During planning, evidence cells describe expected artifacts; during verification, replace those plans with actual references or an explicit pending/blocker explanation. +## Closeout -Keep proposal.md's AC definitions stable. If a criterion is invalid or requirements change, update and re-review the documents before changing implementation or acceptance expectations. Retain the approved deviation in task.md. +Run affected tests, the unified asset validator and deterministic Excel regeneration/check. Confirm every automated affected case has a valid manifest binding and that only changed/incremental entries were regenerated. Preserve historical cases and evidence unless explicitly retired with rationale. diff --git a/.agents/skills/nexent-test-assets/SKILL.md b/.agents/skills/nexent-test-assets/SKILL.md new file mode 100644 index 0000000000..1631caec47 --- /dev/null +++ b/.agents/skills/nexent-test-assets/SKILL.md @@ -0,0 +1,58 @@ +--- +name: nexent-test-assets +description: Create and maintain Nexent's requirement-driven feature catalog, D1-D5 structured cases, change records, fixed automation, implementation manifest, generated Excel baseline, migration inventory, and Mock/Real execution declarations. Use for requirements, bug regressions, test implementation, V5 migration, or formal test-asset consistency work. Excludes the Legacy UT suite. +--- + +# Nexent formal test assets + +Maintain one traceable chain: + +`Requirement or Bug -> Feature -> D1-D5 Case -> Manifest -> Fixed Script -> Result`. + +Paths are relative to the repository root. The structured YAML/JSON files are authoritative; `test/generated/Nexent_测试基线.xlsx` is a deterministic read-only view. + +## Boundaries + +- Do not reuse or register tests from `test/backend`, `test/sdk`, or `test/ext_components` as formal D1 cases. Those are Legacy UT. +- Formal scripts live only below `test/automation/d1` through `test/automation/d5`. +- Design cases before product implementation. Implement fixed scripts and manifest entries after product implementation, except an intentional bug reproduction may be written earlier. +- Update only affected manifest entries, then validate the whole manifest. +- Do not edit the generated Excel workbook directly. +- Do not encode secrets, personal absolute paths, or environment-specific runtime IDs in formal cases or scripts. +- Business tests must not depend on a specific SQL file path. Test migration behavior only at the D5 deployment boundary. +- Store change records by type: requirements in `test/changes/requirements/`, bug fixes in `test/changes/bugs/`, refactors in `test/changes/refactors/`, and test-only fixes in `test/changes/test-fixes/`. Do not place change files directly below `test/changes/`. + +## Select a mode + +| Mode | Use | Required reference | +| --- | --- | --- | +| `requirement-design` | Add or change product behavior and design D1-D5 cases | [lifecycle.md](references/lifecycle.md), [case-design.md](references/case-design.md) | +| `bugfix` | Record a defect, explain the escaped gap, and add/reuse/strengthen regression coverage | [lifecycle.md](references/lifecycle.md), [bugfix.md](references/bugfix.md) | +| `test-implementation` | Implement fixed scripts and incrementally update the manifest | [test-implementation.md](references/test-implementation.md), [manifest.md](references/manifest.md) | +| `migration` | Convert V5, fixed scripts, manifest, and referenced assets without changing behavior | [migration.md](references/migration.md) | +| `mock-migration` | Add Mock/Real profiles after migration equivalence passes | [mock-profiles.md](references/mock-profiles.md) | + +Read only the references needed for the selected mode. + +## Required workflow + +1. Inspect the owning Feature, existing formal Cases, change record, manifest entries, scripts, and repository status. +2. Modify the authoritative YAML/JSON before regenerating derived views. +3. Preserve stable Feature and Case IDs. Retire instead of deleting historical contracts. +4. During requirement design, run `python test/tools/validate_test_assets.py --phase design --generate-excel`. +5. After fixed scripts and manifest entries exist, run `python test/tools/validate_test_assets.py --phase implementation --generate-excel`. Use `python test/tools/generate_excel.py --check` for a read-only Excel drift check. +6. In implementation mode, run the affected selectors and report exact results. A schema-valid asset is not execution evidence. + +## Status rules + +- `active`: required and executable according to its automation field. +- `blocked`: required but missing a declared prerequisite; never count as pass. +- `manual`: intentionally manual and not represented as automated. +- `skipped_by_policy`: excluded by an explicit current product-test policy. +- `retired`: historical behavior no longer executed. + +A2A is in scope across D1-D5, including fixed D4 journeys. OAuth and CAS journeys remain `skipped_by_policy` until their policy changes. + +## Generated Excel layout + +Generate exactly seven sheets: `00_说明`, `01_功能清单`, and `02_D1` through `06_D5`. Each D1-D5 row includes its requirement and business-rule traceability, automation status, framework, script, selector, execution profile, Mock services, logical assets, and readable manifest validation status. Keep contract and implementation hashes in hidden trailing columns. Do not create separate automation or coverage sheets. diff --git a/.agents/skills/nexent-test-assets/references/bugfix.md b/.agents/skills/nexent-test-assets/references/bugfix.md new file mode 100644 index 0000000000..775bc2e03e --- /dev/null +++ b/.agents/skills/nexent-test-assets/references/bugfix.md @@ -0,0 +1,11 @@ +# Bugfix contract + +Every confirmed product bug needs a `test/changes/bugs/*.yaml` record and one of these regression outcomes: + +- reuse a formal Case that already fails for the correct reason; +- strengthen an existing Case and its script; +- add a new Case at the lowest proving stage and any escaped higher stage that needs protection. + +Classify the coverage gap as `missing_case`, `missing_boundary`, `missing_assertion`, `manifest_omission`, `incorrect_skip`, `non_blocking_result`, or `missing_contract`. Do not use a generic statement such as `insufficient coverage`. + +If the feature contract is already correct, do not modify it. If expected behavior changes or was undefined, update the Feature first, then the Cases, product, scripts, and manifest. diff --git a/.agents/skills/nexent-test-assets/references/case-design.md b/.agents/skills/nexent-test-assets/references/case-design.md new file mode 100644 index 0000000000..a59db92fa8 --- /dev/null +++ b/.agents/skills/nexent-test-assets/references/case-design.md @@ -0,0 +1,15 @@ +# D1-D5 case design + +Use `test/schemas/test-case.schema.json` as the exact file contract. + +| Stage | Primary proof | Required boundary | +| --- | --- | --- | +| D1 | Backend unit, SDK unit, frontend component | Isolated behavior; no real external service | +| D2 | API and protocol contract | Request, response, headers, schema, status, compatibility | +| D3 | Runtime integration | Real local Nexent services and a declared external profile | +| D4 | Fixed Playwright user journey | Ordered user actions, per-step observations, final business result | +| D5 | Security, reliability, performance, deployment | Risk, condition/load, metric, threshold, recovery | + +Priority expresses business and regression risk and does not select the stage. Each case proves one coherent behavior. Steps and expected results must be executable without guessing. Include forbidden side effects when failure could mutate state, leak data, invoke downstream services, or contaminate another tenant or session. + +A2A requires applicable cases at D1-D5, including discovery, registration or publishing, binding, invocation, and failure recovery journeys. OAuth and CAS journeys remain policy-skipped. diff --git a/.agents/skills/nexent-test-assets/references/lifecycle.md b/.agents/skills/nexent-test-assets/references/lifecycle.md new file mode 100644 index 0000000000..4aba943b43 --- /dev/null +++ b/.agents/skills/nexent-test-assets/references/lifecycle.md @@ -0,0 +1,32 @@ +# Formal test-asset lifecycle + +## Requirement + +1. Update the owning Feature and business rules. +2. Create the requirement change record under `test/changes/requirements/`, listing added, modified, and retired Feature and Case IDs. +3. Design every applicable D1-D5 case and record justified stage exclusions. +4. Run the design-phase unified validator and regenerate the Excel view. +5. Implement product code. +6. Implement affected fixed scripts, incrementally update manifest entries, and run the implementation-phase unified validator. +7. Run affected D1-D5 cases and record results outside the design assets. + +Do not invent script paths, selectors, hashes, or results during design. + +## Bug + +Use a lightweight bug record under `test/changes/bugs/`. Identify observed versus expected behavior, owning Feature IDs, the stage where the defect escaped, why the existing formal baseline missed it, and whether an existing Case can be reused or strengthened. Update the feature catalog only when the product contract is missing or changes. + +## Change record paths + +| Change type | Required directory | +| --- | --- | +| `requirement` | `test/changes/requirements/` | +| `bugfix` | `test/changes/bugs/` | +| `refactor` | `test/changes/refactors/` | +| `test-fix` | `test/changes/test-fixes/` | + +One file may contain multiple change records only when all records have the same change type. Files directly below `test/changes/` are invalid. + +## Daily ownership + +The product repository owns formal assets. Ubuntu Daily consumes the `develop` versions and never rewrites them. Daily may report drift or missing assets, but fixes return through the product-repository workflow. diff --git a/.agents/skills/nexent-test-assets/references/manifest.md b/.agents/skills/nexent-test-assets/references/manifest.md new file mode 100644 index 0000000000..3ef9dae212 --- /dev/null +++ b/.agents/skills/nexent-test-assets/references/manifest.md @@ -0,0 +1,9 @@ +# D1-D5 implementation manifest + +The manifest maps a formal Case to one or more fixed implementations. It does not duplicate case steps or contain credentials. Files must remain below `test/automation/d1` through `test/automation/d5`; Legacy UT paths are invalid. + +Update only affected entries. Recalculate `contract_hash` when the authoritative Case changes and `implementation_hash` when implementation content changes. After the incremental edit, validate all entries, file paths, selectors, Case metadata, stages, profiles, and hashes. + +Resolve implementation paths before reading them; they must stay inside the case's own stage directory, including after resolving symlinks. Manual, policy-skipped, and retired cases must use the corresponding manifest status. An active automated case requires `implemented`. A blocked automated case may retain an `implemented` entry when the script exists but a runtime prerequisite is missing; this never means the case passed. + +Portability and credential validation covers the entire case payload, not just `test_data`. Credential fields must use a logical reference such as `access_token: {asset_ref: tenant_a_credentials}`. Do not embed literal credentials, including synthetic tokens; declare their fixture through the asset reference. HTTP endpoint paths remain allowed. diff --git a/.agents/skills/nexent-test-assets/references/migration.md b/.agents/skills/nexent-test-assets/references/migration.md new file mode 100644 index 0000000000..9986a8226f --- /dev/null +++ b/.agents/skills/nexent-test-assets/references/migration.md @@ -0,0 +1,7 @@ +# V5 migration + +Migrate V5 Cases, `/home/jason/nexent-test-suite/auto_test` fixed scripts, the existing formal manifest, and every referenced asset dependency. Do not migrate Legacy UT. + +The first pass is behavior-preserving: retain Case IDs, expectations, selectors, and script contents; allow only declared path and format changes. Record static, secret/config, runtime-ID, large-file, and policy-skipped assets in `test/migration/v5-asset-inventory.yaml`. Copy only small non-sensitive stable fixtures into Git. + +Before optimization or Mock conversion, compare migration results on the same product commit, Ubuntu environment, and assets. Explain every Case-set, selector, hash, or result difference. diff --git a/.agents/skills/nexent-test-assets/references/mock-profiles.md b/.agents/skills/nexent-test-assets/references/mock-profiles.md new file mode 100644 index 0000000000..b1c27434f5 --- /dev/null +++ b/.agents/skills/nexent-test-assets/references/mock-profiles.md @@ -0,0 +1,7 @@ +# Mock and Real Smoke profiles + +Introduce profiles only after migration equivalence passes. Supported formal profiles are `mock` and `real_smoke`. Keep results separate by `Case ID + profile`. + +Mock service IDs are `model-provider`, `mcp-server`, `knowledge-provider`, `memory-provider`, `a2a-agent`, and `http-fixture`. OAuth/CAS identity Mock remains optional. PostgreSQL, Elasticsearch, Redis, MinIO, and Nexent application services remain real local services. + +A2A is fully covered: protocol contract, runtime integration, fixed Playwright discovery, registration, binding, and invocation journeys, failure recovery, and Real Smoke. A missing A2A Mock prerequisite is `blocked`, not policy-skipped. diff --git a/.agents/skills/nexent-test-assets/references/test-implementation.md b/.agents/skills/nexent-test-assets/references/test-implementation.md new file mode 100644 index 0000000000..fe9713d5e4 --- /dev/null +++ b/.agents/skills/nexent-test-assets/references/test-implementation.md @@ -0,0 +1,7 @@ +# Fixed test implementation + +Implement only from an active or blocked formal Case. Choose the framework by proof boundary: pytest for Python unit, API, and runtime checks; the frontend component framework for FE-COMP; Playwright for D4; and an appropriate fixed runner for D5. + +Each collected item must expose its Case ID in stable metadata or its test title. Do not create constant assertions, swallow failures, use skip or xfail as completion, or change expectations to mirror the current implementation. Scripts may not depend on developer absolute paths or specific SQL filenames. + +After implementation, collect the exact selector, run it, update only the affected manifest entry, recalculate implementation hashes, and run the full formal-asset validator. diff --git a/.cursor/rules/test_asset_rules.mdc b/.cursor/rules/test_asset_rules.mdc new file mode 100644 index 0000000000..1ab565a3ed --- /dev/null +++ b/.cursor/rules/test_asset_rules.mdc @@ -0,0 +1,7 @@ +--- +description: Formal Nexent feature catalog, D1-D5 cases, automation, manifest, generated Excel, migration, and Mock profiles +globs: test/features/**,test/cases/**,test/changes/**,test/automation/**,test/manifests/**,test/schemas/**,test/tools/**,test/mock-services/** +alwaysApply: false +--- + +Read [.agents/skills/nexent-test-assets/SKILL.md](mdc:.agents/skills/nexent-test-assets/SKILL.md) before working in this rule scope. diff --git a/.gitignore b/.gitignore index 72c36ffd8c..b9f4e0f560 100644 --- a/.gitignore +++ b/.gitignore @@ -94,4 +94,8 @@ agent_repository_frontend .tokensave .playwright-mcp/ # Added by code-review-graph -.code-review-graph/ \ No newline at end of file +.code-review-graph/ + +.codex/skills/openspec-* +# Added by Serena MCP +.serena/ diff --git a/AGENTS.md b/AGENTS.md index f27813c11c..015316d2e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,9 @@ Before editing or reviewing these areas, read the linked skill and only its appl | --- | --- | | Backend endpoints, services, database access, backend/SDK configuration, SQL migrations | [.agents/skills/nexent-backend/SKILL.md](.agents/skills/nexent-backend/SKILL.md) | | Frontend pages, UI, hooks, API services, types, styles, localization | [.agents/skills/nexent-frontend/SKILL.md](.agents/skills/nexent-frontend/SKILL.md) | -| Writing, debugging, or reviewing Python unit tests | [.agents/skills/nexent-python-tests/SKILL.md](.agents/skills/nexent-python-tests/SKILL.md) | +| Requirement or bug lifecycle, SPEC traceability, and delivery gates | [.agents/skills/nexent-spec-coding/SKILL.md](.agents/skills/nexent-spec-coding/SKILL.md) | +| Requirement-driven feature catalog, D1-D5 cases, automation, manifest, and generated Excel baseline | [.agents/skills/nexent-test-assets/SKILL.md](.agents/skills/nexent-test-assets/SKILL.md) | +| Maintaining the existing implementation-oriented Python unit tests | [.agents/skills/nexent-python-tests/SKILL.md](.agents/skills/nexent-python-tests/SKILL.md) | These Markdown files are the maintained rules. `.cursor/rules/` contains compatibility entry points with the original Cursor triggers. Edit canonical rules when changing policy. See [migration decisions](docs/agent-rules-migration.md) when maintaining this setup. diff --git a/VERSION b/VERSION index 06646d7cc8..873ca0fa62 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.6.1 +v2.7.0 diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 7c404a70d0..0734c4dd73 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -56,7 +56,7 @@ query_sub_agent_relations, resolve_sub_agent_version_no, ) -from database.agent_version_db import query_current_version_no +from database.agent_version_db import query_current_version_no, update_agent_snapshot from database import skill_db from database.tool_db import query_tools_by_ids, search_tools_for_sub_agent from database.model_management_db import get_model_records, get_model_by_model_id @@ -69,6 +69,7 @@ from utils.automation_tool_prompt import build_automation_tool_policy from utils.context_utils import build_context_inputs from utils.http_client_utils import create_httpx_client +from utils.mcp_url_utils import get_tenant_local_mcp_server from utils.redis_utils import get_redis_client from consts.const import ( AGENT_WORKSPACE_ROOT, @@ -79,20 +80,40 @@ LANGUAGE, LLM_INCLUDE_LOGPROBS, LOCAL_MCP_SERVER, + MCP_REQUEST_TIMEOUT_SECONDS, + TOKEN, MINIO_DEFAULT_BUCKET, MODEL_CONFIG_MAPPING, NEXENT_SANDBOX_WORKSPACE_VOLUME, RUNTIME_MCP_CLOSE_TIMEOUT_SECONDS, RUNTIME_MCP_TOOL_TIMEOUT_SECONDS, + RUNTIME_PARALLEL_EXECUTOR_TIMEOUT_SECONDS, ) from consts.model import ToolParamsRequest -from consts.exceptions import ValidationError +from consts.exceptions import ValidationError, WorkbenchError from consts.tool_labels import SYSTEM_MANAGED_TOOL_NAMES +from .tool_user_context import resolve_tool_user_context + logger = logging.getLogger("create_agent_info") logger.setLevel(logging.INFO) +def _build_parallel_executor_tool_config(default_timeout_seconds: int) -> ToolConfig: + return ToolConfig( + class_name=ParallelExecutorTool.__name__, + name=ParallelExecutorTool.name, + description=ParallelExecutorTool.description, + inputs=json.dumps( + ParallelExecutorTool.inputs_for_timeout(default_timeout_seconds), + ensure_ascii=False, + ), + output_type=ParallelExecutorTool.output_type, + params={"default_timeout_seconds": default_timeout_seconds}, + source="local", + ) + + def _create_fixed_search_memory_tool(): """Create the internal search tool lazily to keep import boundaries stable.""" from nexent.core.tools.search_memory_tool import SearchMemoryTool @@ -114,6 +135,45 @@ def _select_agent_model_id( return agent_model_ids[0] if agent_model_ids else None +def _ensure_agent_reasoning_snapshot( + agent_id: int, + tenant_id: str, + version_no: int, +) -> None: + """Backfill reasoning ownership for agents created before Scheme B.""" + from services.agent_reasoning_service import snapshot_agent_reasoning_config + + try: + agent_info = search_agent_info_by_agent_id( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + if not agent_info: + return + current_overrides = agent_info.get("model_params_override") + updated_overrides = snapshot_agent_reasoning_config( + model_ids=agent_info.get("model_ids"), + requested_overrides=current_overrides, + existing_overrides=current_overrides, + tenant_id=agent_info.get("tenant_id") or tenant_id, + ) + if updated_overrides == current_overrides: + return + update_agent_snapshot( + agent_id=agent_id, + tenant_id=agent_info.get("tenant_id") or tenant_id, + version_no=version_no, + agent_data={"model_params_override": updated_overrides}, + ) + except Exception as exc: # pragma: no cover - defensive migration fallback + logger.warning( + "Failed to backfill agent reasoning snapshot for agent %s: %s", + agent_id, + exc, + ) + + def _get_external_provider_service_for_search(): """Resolve the external provider service used to search enabled providers.""" return get_memory_external_provider_service() @@ -199,6 +259,20 @@ def _build_effective_knowledge_base_summary( "tokenizer_family", ) +COMMON_REASONING_LEVELS = ("low", "medium", "high") +COMMON_REASONING_DEFAULT = "auto" + +def _is_thinking_enabled(extra_params: Optional[Dict[str, Any]]) -> bool: + """Return whether the model explicitly or implicitly enables reasoning.""" + if not isinstance(extra_params, dict): + return False + if isinstance(extra_params.get("enable_thinking"), bool): + return extra_params["enable_thinking"] + return ( + isinstance(extra_params.get("reasoning_effort"), str) + or isinstance(extra_params.get("reasoning_budget_tokens"), int) + ) + def _build_extra_body(extra_params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """Build extra_body for ModelConfig from model_record_t.extra_params. @@ -210,11 +284,82 @@ def _build_extra_body(extra_params: Optional[Dict[str, Any]]) -> Optional[Dict[s if not extra_params or not isinstance(extra_params, dict): return None extra_body = dict(extra_params) + # These are dedicated ModelConfig fields, not provider request-body keys. + extra_body.pop("reasoning_effort", None) + extra_body.pop("reasoning_budget_tokens", None) custom = extra_body.pop("__custom__", None) if custom and isinstance(custom, dict): extra_body.update(custom) return extra_body if extra_body else None + +def _resolve_reasoning_capability( + model_name: str, + base_url: Optional[str], + provider_hint: Optional[str], +) -> Optional[Dict[str, Any]]: + """Resolve catalog reasoning metadata without coupling import-time setup.""" + try: + from configs.model_catalog_loader import resolve_reasoning_capability + except ImportError: + return None + return resolve_reasoning_capability( + model_name=model_name, + base_url=base_url, + provider_hint=provider_hint, + ) + + +def _resolve_model_reasoning_effort( + extra_params: Optional[Dict[str, Any]], + capability: Optional[Dict[str, Any]], +) -> Optional[str]: + """Resolve the enabled model's effort from its supported profile.""" + if not isinstance(extra_params, dict) or not _is_thinking_enabled(extra_params): + return None + if extra_params.get("reasoning_effort") == "auto": + return None + if isinstance(capability, dict) and capability.get("status") == "supported": + levels = capability.get("levels") or list(COMMON_REASONING_LEVELS) + default = capability.get("default") + if default not in levels: + default = COMMON_REASONING_DEFAULT + else: + # Unknown/custom model IDs still use the common generic profile. The + # provider remains the source of truth when a concrete value is sent. + levels = list(COMMON_REASONING_LEVELS) + default = COMMON_REASONING_DEFAULT + saved = extra_params.get("reasoning_effort") + if saved in levels: + return saved + # Auto is the universal fallback: omit the provider-specific effort so the + # model can choose its own reasoning depth. + return None if saved is None or default == COMMON_REASONING_DEFAULT else default + + +def _resolve_model_reasoning_budget( + extra_params: Optional[Dict[str, Any]], + capability: Optional[Dict[str, Any]], +) -> Optional[int]: + """Resolve a persisted numeric reasoning budget within the source range.""" + if not isinstance(extra_params, dict) or not _is_thinking_enabled(extra_params): + return None + value = extra_params.get("reasoning_budget_tokens") + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return None + controls = capability.get("controls") if isinstance(capability, dict) else None + budget_control = next( + (control for control in controls or [] + if isinstance(control, dict) and control.get("type") == "budget_tokens"), + None, + ) + if isinstance(budget_control, dict): + minimum = budget_control.get("min") + maximum = budget_control.get("max") + if isinstance(minimum, int) and isinstance(maximum, int): + return min(maximum, max(minimum, value)) + return value + # Per-process dedup for the "model has no capacity configured" warning. # Without this, every agent run logs the same line, drowning real signal. # Keyed by model_id; cleared only on process restart. @@ -583,6 +728,39 @@ def _build_internal_s3_url(file: dict) -> str: return "s3:/" + url +def _collect_run_minio_files( + minio_files: Optional[List[Dict[str, Any]]], + history: Optional[List[Any]], + max_files: int = 50, +) -> List[Dict[str, Any]]: + """Collect current and historical attachments for one authorized Agent run.""" + seen_urls: set[str] = set() + collected: List[Dict[str, Any]] = [] + sources = [minio_files] + for message in history or []: + attachments = ( + message.get("minio_files") + if isinstance(message, dict) + else getattr(message, "minio_files", None) + ) + sources.append(attachments) + + for attachments in sources: + if not isinstance(attachments, list): + continue + for file in attachments: + if not isinstance(file, dict) or not file.get("name"): + continue + s3_url = _build_internal_s3_url(file) + if not s3_url or s3_url in seen_urls: + continue + seen_urls.add(s3_url) + collected.append(file) + if len(collected) == max_files: + return collected + return collected + + def _safe_workspace_segment(value: Any, fallback: str) -> str: """Return a filesystem-safe user path segment.""" normalized = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value or "")).strip("._") @@ -599,6 +777,37 @@ def _build_run_workspace(user_id: str, run_id: str) -> str: ) +def _materialize_runtime_skill_snapshot( + snapshot: List[Dict[str, Any]], + workspace_path: str, + tenant_id: str, +) -> List[Dict[str, Any]]: + """Materialize frozen Skill files below the run workspace without mutating input.""" + effective = copy.deepcopy(snapshot) + snapshot_root = (Path(workspace_path).resolve() / ".skill_snapshot").resolve() + tenant_root = (snapshot_root / tenant_id).resolve() + if not tenant_root.is_relative_to(snapshot_root): + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + for skill in effective: + skill_name = str(skill.get("name") or "") + if not skill_name or Path(skill_name).name != skill_name or skill_name in {".", ".."}: + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + skill_root = (tenant_root / skill_name).resolve() + if not skill_root.is_relative_to(tenant_root): + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + files = skill.pop("files", []) + for relative_path, content in files: + destination = (skill_root / str(relative_path)).resolve() + if not destination.is_relative_to(skill_root): + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(bytes(content)) + if not (skill_root / "SKILL.md").is_file(): + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + skill["_snapshot_root"] = str(snapshot_root) + return effective + + def _validate_run_minio_files( minio_files: Optional[List[Dict[str, Any]]], user_id: str, @@ -616,7 +825,8 @@ def _validate_run_minio_files( def _get_skills_for_template( agent_id: int, tenant_id: str, - version_no: int = 0 + version_no: int = 0, + runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, ) -> List[dict]: """Get skills list for prompt template injection. @@ -629,13 +839,16 @@ def _get_skills_for_template( List of skill dicts with name and description """ try: - from management.services.skill.service import SkillService - skill_service = SkillService() - enabled_skills = skill_service.get_enabled_skills_for_agent( - agent_id=agent_id, - tenant_id=tenant_id, - version_no=version_no - ) + if runtime_skill_snapshot is None: + from management.services.skill.service import SkillService + skill_service = SkillService() + enabled_skills = skill_service.get_enabled_skills_for_agent( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + else: + enabled_skills = runtime_skill_snapshot return [ {"name": s.get("name", ""), "description": s.get("description", "")} for s in enabled_skills @@ -798,6 +1011,7 @@ def _get_skill_script_tools( tenant_id: str, version_no: int = 0, runtime_file_context: Optional[Dict[str, Any]] = None, + runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, ) -> List[ToolConfig]: """Get tool config for skill script execution and skill reading. @@ -817,15 +1031,27 @@ def _get_skill_script_tools( "version_no": version_no, } file_context = dict(runtime_file_context or {}) + skill_snapshot_root = next( + ( + str(skill.get("_snapshot_root")) + for skill in (runtime_skill_snapshot or []) + if skill.get("_snapshot_root") + ), + None, + ) skill_config_values: Dict[str, Dict[str, Any]] = {} try: from management.services.skill.service import SkillService - enabled_skills = SkillService(tenant_id=tenant_id).get_enabled_skills_for_agent( - agent_id=agent_id, - tenant_id=tenant_id, - version_no=version_no, + enabled_skills = ( + SkillService(tenant_id=tenant_id).get_enabled_skills_for_agent( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + if runtime_skill_snapshot is None + else runtime_skill_snapshot ) skill_config_values = { skill.get("name", ""): dict(skill.get("config_values") or {}) @@ -836,7 +1062,7 @@ def _get_skill_script_tools( logger.warning(f"Failed to resolve effective skill configuration: {exc}", exc_info=True) try: - return [ + tools = [ ToolConfig( class_name="RunSkillScriptTool", name="run_skill_script", @@ -854,7 +1080,8 @@ def _get_skill_script_tools( ), output_type="string", params={ - "local_skills_dir": CONTAINER_SKILLS_PATH, + "local_skills_dir": skill_snapshot_root or CONTAINER_SKILLS_PATH, + "isolated_skills_root": bool(skill_snapshot_root), "workspace_path": file_context.get("workspace_path"), "authorized_skill_names": sorted(skill_config_values), }, @@ -868,7 +1095,11 @@ def _get_skill_script_tools( description="Read skill execution guide and optional additional files. Always reads SKILL.md first, then optionally reads additional files.", inputs='{"skill_name": "str", "additional_files": "list[str]"}', output_type="string", - params={"local_skills_dir": CONTAINER_SKILLS_PATH}, + params={ + "local_skills_dir": skill_snapshot_root or CONTAINER_SKILLS_PATH, + "isolated_skills_root": bool(skill_snapshot_root), + **({"authorized_skill_names": sorted(skill_config_values)} if runtime_skill_snapshot is not None else {}), + }, source="builtin", usage="builtin", metadata=skill_context, @@ -880,8 +1111,9 @@ def _get_skill_script_tools( inputs='{"skill_name": "str"}', output_type="string", params={ - "local_skills_dir": CONTAINER_SKILLS_PATH, + "local_skills_dir": skill_snapshot_root or CONTAINER_SKILLS_PATH, "config_overrides": skill_config_values, + **({"authorized_skill_names": sorted(skill_config_values)} if runtime_skill_snapshot is not None else {}), }, source="builtin", usage="builtin", @@ -945,6 +1177,9 @@ def _get_skill_script_tools( metadata=file_context, ), ] + if runtime_skill_snapshot is not None: + tools = [tool for tool in tools if tool.class_name != "WriteSkillFileTool"] + return tools except Exception as e: logger.warning(f"Failed to load skill script tool: {e}") return [] @@ -955,13 +1190,19 @@ async def create_model_config_list(tenant_id): model_list = [] extra_body = {"logprobs": True} if LLM_INCLUDE_LOGPROBS else None for record in records: + model_name = add_repo_to_name( + model_repo=record["model_repo"], + model_name=record["model_name"], + ) + reasoning_capability = _resolve_reasoning_capability( + model_name=model_name, + base_url=record.get("base_url"), + provider_hint=record.get("model_factory"), + ) model_list.append( ModelConfig(cite_name=record["display_name"], api_key=record.get("api_key", ""), - model_name=add_repo_to_name( - model_repo=record["model_repo"], - model_name=record["model_name"], - ), + model_name=model_name, url=record["base_url"], ssl_verify=record.get("ssl_verify", True), model_factory=record.get("model_factory"), @@ -983,6 +1224,14 @@ async def create_model_config_list(tenant_id): # temperature/top_p/extra_params flow into SDK. temperature=record.get("temperature"), top_p=record.get("top_p"), + enable_thinking=_is_thinking_enabled(record.get("extra_params")), + reasoning_capability=reasoning_capability, + reasoning_effort=_resolve_model_reasoning_effort( + record.get("extra_params"), reasoning_capability + ), + reasoning_budget_tokens=_resolve_model_reasoning_budget( + record.get("extra_params"), reasoning_capability + ), extra_body=_build_extra_body(record.get("extra_params")))) # fit for old version, main_model and sub_model use default model main_model_config = tenant_config_manager.get_model_config( @@ -1073,12 +1322,30 @@ async def create_agent_config( automation_has_attachments: bool = False, runtime_knowledge_context: Optional[Dict[str, str]] = None, runtime_file_context: Optional[Dict[str, Any]] = None, + runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, + runtime_knowledge_tools: Optional[List[Dict[str, Any]]] = None, + runtime_sub_agent_mounts: Optional[List[Dict[str, Any]]] = None, ): normalized_tool_params = _normalize_tool_params_request(tool_params) agent_info = search_agent_info_by_agent_id( agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) # create sub agent + child_tool_params = normalized_tool_params + child_knowledge_context = runtime_knowledge_context + if runtime_knowledge_tools is not None: + child_tool_params = normalized_tool_params.model_copy(deep=True) + root_override = child_tool_params.agents.get(agent_info.get("name")) + if root_override is not None: + from services.runtime_knowledge_mount import MANAGED_CLASSES + + managed_names = MANAGED_CLASSES | { + tool.get("name") for tool in runtime_knowledge_tools + if tool.get("class_name") in MANAGED_CLASSES + } + root_override.tools = {name: params for name, params in root_override.tools.items() + if name not in managed_names} + child_knowledge_context = None sub_agent_relations = query_sub_agent_relations( main_agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) managed_agents = [] @@ -1098,12 +1365,67 @@ async def create_agent_config( allow_memory_search=allow_memory_search, version_no=sub_agent_version_no, override_model_id=None, - tool_params=normalized_tool_params, + tool_params=child_tool_params, conversation_id=conversation_id, include_automation_tool=False, - runtime_knowledge_context=runtime_knowledge_context, + runtime_knowledge_context=child_knowledge_context, runtime_file_context=runtime_file_context, + # Workbench overlays replace Skills on the effective root only. + runtime_skill_snapshot=None, + ) + # Persisted Agent relations are rendered into the manager prompt by + # their configured business name. Keep the callable registered in the + # Python executor under that same name; the generic runtime identity + # assigned by the recursive builder is only appropriate for roots and + # Workbench-selected dynamic children. + sub_agent_config.invocation_name = sub_agent_config.name + managed_agents.append(sub_agent_config) + + # Workbench-selected Agents are request-scoped children. They are built + # from pinned published versions but never persisted as Agent relations. + used_invocation_names = { + str(agent.invocation_name or agent.name) for agent in managed_agents + } + for mount in runtime_sub_agent_mounts or []: + sub_agent_id = int(mount["agent_id"]) + sub_agent_version_no = int(mount["version_no"]) + if sub_agent_id == int(agent_id) and sub_agent_version_no == int(version_no): + raise WorkbenchError("WORKBENCH_AGENT_CYCLE") + sub_agent_config = await create_agent_config( + agent_id=sub_agent_id, + tenant_id=tenant_id, + user_id=user_id, + language=language, + last_user_query=last_user_query, + allow_memory_search=allow_memory_search, + version_no=sub_agent_version_no, + override_model_id=None, + tool_params=child_tool_params, + conversation_id=conversation_id, + include_automation_tool=False, + runtime_knowledge_context=child_knowledge_context, + runtime_file_context=runtime_file_context, + runtime_skill_snapshot=None, + runtime_knowledge_tools=None, + runtime_sub_agent_mounts=None, + ) + invocation_name = str( + mount.get("invocation_name") + or f"agent_{sub_agent_id}_v{sub_agent_version_no}" + ) + if invocation_name in used_invocation_names: + raise WorkbenchError("WORKBENCH_AGENT_NAME_CONFLICT") + used_invocation_names.add(invocation_name) + sub_agent_config.name = invocation_name + sub_agent_config.invocation_name = invocation_name + sub_agent_config.runtime_ref = str( + mount.get("runtime_ref") + or f"agent:{sub_agent_id}:v{sub_agent_version_no}" + ) + sub_agent_config.display_name = str( + mount.get("display_name") or sub_agent_config.display_name or invocation_name ) + sub_agent_config.origin = "PERSISTED" managed_agents.append(sub_agent_config) # create external A2A agents (synchronous function, no await needed) @@ -1115,6 +1437,8 @@ async def create_agent_config( user_id, version_no=version_no, tool_params=normalized_tool_params, + runtime_skill_snapshot=runtime_skill_snapshot, + runtime_knowledge_tools=runtime_knowledge_tools, ) memory_tool_names = {"store_memory", "search_memory"} tool_list = [tool for tool in tool_list if tool.name not in memory_tool_names] @@ -1122,15 +1446,7 @@ async def create_agent_config( # Append parallel_executor as an always-available system-managed tool. # Memory handling is wired separately below: only store_memory is exposed # to the model, while search_memory runs once during preparation. - tool_list.append(ToolConfig( - class_name=ParallelExecutorTool.__name__, - name=ParallelExecutorTool.name, - description=ParallelExecutorTool.description, - inputs=json.dumps(ParallelExecutorTool.inputs, ensure_ascii=False), - output_type=ParallelExecutorTool.output_type, - params={}, - source="local", - )) + tool_list.append(_build_parallel_executor_tool_config(RUNTIME_PARALLEL_EXECUTOR_TIMEOUT_SECONDS)) if ( include_automation_tool @@ -1429,7 +1745,12 @@ async def create_agent_config( enable_context_manager = agent_info.get("enable_context_manager", False) # Get the skills included in ContextManager items. - skills = _get_skills_for_template(agent_id, tenant_id, version_no) + skills = _get_skills_for_template( + agent_id, + tenant_id, + version_no, + runtime_skill_snapshot=runtime_skill_snapshot, + ) is_manager = len(managed_agents) > 0 or len(external_a2a_agents) > 0 builtin_tools = _get_skill_script_tools( @@ -1437,6 +1758,7 @@ async def create_agent_config( tenant_id, version_no, runtime_file_context=runtime_file_context, + runtime_skill_snapshot=runtime_skill_snapshot, ) available_tools = tool_list + builtin_tools @@ -1587,6 +1909,7 @@ async def create_agent_config( agent_config = AgentConfig( name="undefined" if agent_info["name"] is None else agent_info["name"], + display_name=agent_info.get("display_name"), description="undefined" if agent_info["description"] is None else agent_info["description"], prompt_templates=await prepare_prompt_templates( is_manager=len(managed_agents) > 0 or len(external_a2a_agents) > 0, @@ -1599,6 +1922,7 @@ async def create_agent_config( model_name=model_name, provide_run_summary=agent_info.get("provide_run_summary", False), allow_chat_metadata=agent_info.get("allow_chat_metadata", False), + enable_protocol_repair_retry=agent_info.get("enable_protocol_repair_retry") is True, managed_agents=managed_agents, external_a2a_agents=external_a2a_agents, context_manager_config=cm_config, @@ -1609,6 +1933,12 @@ async def create_agent_config( verification_config=AgentVerificationConfig.model_validate(agent_info.get("verification_config") or {}), enable_planning=enable_planning, ) + agent_config.agent_id = agent_id + agent_config.version_no = int(version_no) + agent_config.invocation_name = f"agent_{agent_id}_v{int(version_no)}" + agent_config.runtime_ref = f"agent:{agent_id}:v{int(version_no)}" + agent_config.display_name = agent_info.get("display_name") or agent_config.name + agent_config.origin = "PERSISTED" return agent_config @@ -1616,6 +1946,7 @@ def _resolve_runtime_tool_records( agent_id: int, tenant_id: str, version_no: int = 0, + runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, ) -> List[Dict[str, Any]]: """Merge explicitly enabled tools with tools required by enabled skills.""" explicit_tools = search_tools_for_sub_agent( @@ -1629,24 +1960,37 @@ def _resolve_runtime_tool_records( dependency_values: Dict[int, Dict[str, Any]] = {} dependency_sources: Dict[int, Dict[str, str]] = {} - enabled_skill_instances = skill_db.search_skills_for_agent( - agent_id=agent_id, - tenant_id=tenant_id, - version_no=version_no, + enabled_skill_instances = ( + skill_db.search_skills_for_agent( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + if runtime_skill_snapshot is None + else runtime_skill_snapshot ) for skill_instance in enabled_skill_instances: - skill = skill_db.get_skill_by_id(skill_instance.get("skill_id"), tenant_id) + skill = ( + skill_db.get_skill_by_id(skill_instance.get("skill_id"), tenant_id) + if runtime_skill_snapshot is None + else skill_instance + ) if not skill: continue effective_config = dict(skill.get("config_values") or {}) - effective_config.update(skill_instance.get("config_values") or {}) + if runtime_skill_snapshot is None: + effective_config.update(skill_instance.get("config_values") or {}) skill_name = skill.get("name") or str(skill.get("skill_id")) for tool_id in skill.get("tool_ids") or []: if tool_id in explicit_tool_ids: continue values = dependency_values.setdefault(tool_id, {}) sources = dependency_sources.setdefault(tool_id, {}) + runtime_definition = next((item for item in skill.get("tool_definitions") or [] if item.get("tool_id") == tool_id), {}) + runtime_parameter_names = {param.get("name") for param in runtime_definition.get("params") or []} for name, value in effective_config.items(): + if runtime_skill_snapshot is not None and name not in runtime_parameter_names: + continue if name in values and values[name] != value: raise ValidationError( f"Skills '{sources[name]}' and '{skill_name}' configure " @@ -1659,7 +2003,11 @@ def _resolve_runtime_tool_records( if not implicit_tool_ids: return explicit_tools - implicit_definitions = query_tools_by_ids(list(implicit_tool_ids)) + implicit_definitions = ( + query_tools_by_ids(list(implicit_tool_ids)) + if runtime_skill_snapshot is None + else [definition for skill in runtime_skill_snapshot for definition in skill.get("tool_definitions") or []] + ) definitions_by_id = {tool.get("tool_id"): tool for tool in implicit_definitions} missing_tool_ids = implicit_tool_ids - set(definitions_by_id) if missing_tool_ids: @@ -1690,6 +2038,8 @@ async def create_tool_config_list( user_id, version_no: int = 0, tool_params: Optional[ToolParamsRequest | Dict[str, Any]] = None, + runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, + runtime_knowledge_tools: Optional[List[Dict[str, Any]]] = None, ): tool_config_list = [] langchain_tools = await discover_langchain_tools() @@ -1699,9 +2049,18 @@ async def create_tool_config_list( agent_id=agent_id, tenant_id=tenant_id, version_no=version_no, + runtime_skill_snapshot=runtime_skill_snapshot, ) # Look up agent name for use in error messages. + if runtime_knowledge_tools is not None: + from services.runtime_knowledge_mount import MANAGED_CLASSES + + tools_list = [tool for tool in tools_list if tool.get("class_name") not in MANAGED_CLASSES] + tools_list.extend(copy.deepcopy([ + tool for tool in runtime_knowledge_tools if tool.get("class_name") in MANAGED_CLASSES + ])) + # Agent name is optional for tool_params matching (matching uses tool identifiers only), # but we include it in error messages so callers can identify which agent/tool caused a failure. agent_info = search_agent_info_by_agent_id(agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) @@ -1880,7 +2239,8 @@ async def create_tool_config_list( # Build display_name to index_name mapping for LLM parameter conversion # Also build reverse mapping (index_name -> display_name) for knowledge_base_summary - configured_index_names = tool_config.params.get("index_names", []) + configured_index_names = tool_config.params.get("index_names") or [] + tool_config.params["index_names"] = configured_index_names # Enforce knowledge-base-level read permission for the chatting user. # Agent-level permission controls "who can use this agent", but each knowledge @@ -2081,37 +2441,7 @@ async def join_minio_file_description_to_query( Modified query with file descriptions appended """ final_query = query - seen_urls: set[str] = set() - all_files: list[dict] = [] - - # Collect files from current message first (higher priority) - if minio_files and isinstance(minio_files, list): - for file in minio_files: - if isinstance(file, dict) and file.get("name") and (file.get("url") or file.get("object_name")): - s3_url = _build_internal_s3_url(file) - if not s3_url: - continue - if s3_url not in seen_urls: - seen_urls.add(s3_url) - all_files.append(file) - - # Collect files from historical messages (lower priority, already-deduped) - if history and isinstance(history, list): - for msg in history: - if isinstance(msg, dict) and msg.get("minio_files"): - for file in msg["minio_files"]: - if isinstance(file, dict) and file.get("name") and (file.get("url") or file.get("object_name")): - s3_url = _build_internal_s3_url(file) - if not s3_url: - continue - if s3_url not in seen_urls: - seen_urls.add(s3_url) - all_files.append(file) - - # Enforce file count limit (keep most recent files by truncating from the end) - if len(all_files) > max_files: - all_files = all_files[:max_files] - logger.info(f"File list truncated from {len(all_files)} to {max_files} files") + all_files = _collect_run_minio_files(minio_files, history, max_files=max_files) if all_files: file_descriptions: list[str] = [] @@ -2239,6 +2569,52 @@ def check_agent_tools(agent_config: AgentConfig): return list(used_mcp_urls) +def apply_root_generation_overlay( + model_list: List[ModelConfig], + agent_config: AgentConfig, + generation_config: Optional[Dict[str, Any]], +) -> None: + """Add a root-only model alias without changing child Agent aliases.""" + if not generation_config: + return + for model_config in model_list: + if model_config.cite_name != agent_config.model_name: + continue + extra_body = dict(model_config.extra_body or {}) + deep_thinking = bool(generation_config.get("deep_thinking")) + extra_body["enable_thinking"] = deep_thinking + # Effort is a dedicated ModelConfig field, not an extra_body override. + # Clear inherited model settings so the Workbench choice is authoritative. + extra_body.pop("reasoning_effort", None) + extra_body.pop("reasoning_budget_tokens", None) + root_model = model_config.model_copy( + deep=True, + update={ + "cite_name": "workbench_root_model", + "enable_thinking": deep_thinking, + "reasoning_effort": ( + (generation_config.get("thinking_effort") or "low") + if deep_thinking else None + ), + "reasoning_budget_tokens": None, + "temperature": ( + generation_config.get("temperature") + if generation_config.get("temperature") is not None + else model_config.temperature + ), + "top_p": ( + generation_config.get("top_p") + if generation_config.get("top_p") is not None + else model_config.top_p + ), + "extra_body": extra_body, + }, + ) + model_list.append(root_model) + agent_config.model_name = root_model.cite_name + return + + async def create_agent_run_info( agent_id, minio_files, @@ -2251,6 +2627,8 @@ async def create_agent_run_info( is_debug: bool = False, override_version_no: int | None = None, override_model_id: int | None = None, + reasoning_effort: str | None = None, + reasoning_budget_tokens: int | None = None, requested_output_tokens: int | None = None, tool_params: Optional[ToolParamsRequest | Dict[str, Any]] = None, conversation_id: Optional[int] = None, @@ -2258,10 +2636,21 @@ async def create_agent_run_info( enable_planning: bool = False, enable_automation_tool: bool = True, runtime_knowledge_context: Optional[Dict[str, str]] = None, + runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, + runtime_knowledge_tools: Optional[List[Dict[str, Any]]] = None, + runtime_generation_config: Optional[Dict[str, Any]] = None, + runtime_sub_agent_mounts: Optional[List[Dict[str, Any]]] = None, ): workspace_run_id = uuid.uuid4().hex workspace_path = _build_run_workspace(user_id, workspace_run_id) - _validate_run_minio_files(minio_files, user_id, tenant_id) + if runtime_skill_snapshot is not None: + runtime_skill_snapshot = _materialize_runtime_skill_snapshot( + runtime_skill_snapshot, + workspace_path, + tenant_id, + ) + effective_minio_files = _collect_run_minio_files(minio_files, history) + _validate_run_minio_files(effective_minio_files, user_id, tenant_id) runtime_file_context = { "workspace_path": workspace_path, "minio_client": minio_client, @@ -2284,10 +2673,16 @@ async def create_agent_run_info( version_no = 0 logger.info(f"Agent {agent_id} has no published version, using draft version 0") + _ensure_agent_reasoning_snapshot( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=version_no, + ) + final_query = await join_minio_file_description_to_query( minio_files=minio_files, query=query, - history=history + history=history, ) model_list = await create_model_config_list(tenant_id) create_config_kwargs = { @@ -2304,6 +2699,8 @@ async def create_agent_run_info( } if runtime_knowledge_context is not None: create_config_kwargs["runtime_knowledge_context"] = runtime_knowledge_context + if runtime_skill_snapshot is not None: + create_config_kwargs["runtime_skill_snapshot"] = runtime_skill_snapshot if enable_automation_tool and not is_debug and conversation_id is not None: create_config_kwargs.update({ "include_automation_tool": True, @@ -2318,6 +2715,10 @@ async def create_agent_run_info( if context_policy is not None: create_config_kwargs["request_context_policy"] = context_policy + if runtime_knowledge_tools is not None: + create_config_kwargs["runtime_knowledge_tools"] = runtime_knowledge_tools + if runtime_sub_agent_mounts is not None: + create_config_kwargs["runtime_sub_agent_mounts"] = runtime_sub_agent_mounts agent_config = await create_agent_config(**create_config_kwargs, tool_params=tool_params) # v2.6.0: Apply per-agent model params override to model_config_list. @@ -2359,6 +2760,21 @@ async def create_agent_run_info( if override_extra and isinstance(override_extra, dict): merged = dict(mc.extra_body or {}) for k, v in override_extra.items(): + if k == "enable_thinking": + if isinstance(v, bool): + mc.enable_thinking = v + if not v: + mc.reasoning_effort = None + mc.reasoning_budget_tokens = None + continue + if k == "reasoning_effort": + if isinstance(v, str): + mc.reasoning_effort = v + continue + if k == "reasoning_budget_tokens": + if isinstance(v, int) and not isinstance(v, bool) and v > 0: + mc.reasoning_budget_tokens = v + continue if k == "__custom__" and isinstance(v, dict): for custom_key, custom_value in v.items(): # A null custom value is an explicit @@ -2372,10 +2788,80 @@ async def create_agent_run_info( else: merged[k] = v mc.extra_body = merged if merged else None + if override_entry.get("reasoning_effort") is not None: + mc.reasoning_effort = override_entry["reasoning_effort"] + if override_entry.get("reasoning_budget_tokens") is not None: + budget = override_entry["reasoning_budget_tokens"] + if isinstance(budget, int) and not isinstance(budget, bool) and budget > 0: + mc.reasoning_budget_tokens = budget break + # Request-scoped Workbench settings apply only to the root model. Static + # children retain the model aliases from their published versions. + # Static children keep their published aliases and never inherit this overlay. + apply_root_generation_overlay( + model_list, + agent_config, + runtime_generation_config, + ) + + # A request-level effort is valid only when the selected model's switch is + # enabled. Known capabilities use their declared levels. Unknown/custom + # models use the common generic profile and let the provider reject an + # unsupported concrete value through the normal reasoning error path. + # ``auto`` is represented by an omitted per-request effort. The selected + # model keeps reasoning enabled, but the provider chooses the depth. + if reasoning_effort == "auto": + reasoning_effort = None + if reasoning_effort is not None: + selected_config = next( + (mc for mc in model_list if mc.cite_name == agent_config.model_name), + None, + ) + capability = selected_config.reasoning_capability if selected_config else None + if not selected_config or not selected_config.enable_thinking: + raise ValidationError( + "The selected model does not support the requested reasoning effort" + ) + if isinstance(capability, dict) and capability.get("status") == "supported": + supported_levels = capability.get("levels") or list(COMMON_REASONING_LEVELS) + else: + supported_levels = list(COMMON_REASONING_LEVELS) + if reasoning_effort not in supported_levels: + raise ValidationError( + "The selected model does not support the requested reasoning effort" + ) + selected_config.reasoning_effort = reasoning_effort + + if reasoning_budget_tokens is not None: + selected_config = next( + (mc for mc in model_list if mc.cite_name == agent_config.model_name), + None, + ) + capability = selected_config.reasoning_capability if selected_config else None + if not selected_config or not selected_config.enable_thinking: + raise ValidationError( + "The selected model does not support the requested reasoning budget" + ) + controls = capability.get("controls") if isinstance(capability, dict) else None + budget_control = next( + (control for control in controls or [] + if isinstance(control, dict) and control.get("type") == "budget_tokens"), + None, + ) + minimum = budget_control.get("min") if isinstance(budget_control, dict) else None + maximum = budget_control.get("max") if isinstance(budget_control, dict) else None + if not isinstance(minimum, int) or not isinstance(maximum, int): + raise ValidationError( + "The selected model does not support the requested reasoning budget" + ) + if reasoning_budget_tokens < minimum or reasoning_budget_tokens > maximum: + raise ValidationError( + f"Reasoning budget must be between {minimum} and {maximum} tokens" + ) + selected_config.reasoning_budget_tokens = reasoning_budget_tokens remote_mcp_list = await get_remote_mcp_server_list(tenant_id=tenant_id, is_need_auth=True) - default_mcp_url = urljoin(LOCAL_MCP_SERVER, "sse") + default_mcp_url = get_tenant_local_mcp_server(tenant_id) remote_mcp_list.append({ "remote_mcp_server_name": "outer-apis", "remote_mcp_server": default_mcp_url, @@ -2404,6 +2890,10 @@ async def create_agent_run_info( } if url == default_mcp_url: mcp_config["httpx_client_factory"] = create_httpx_client + mcp_config["headers"] = { + "X-Tenant-ID": str(tenant_id), + "X-Nexent-Internal-Token": TOKEN, + } headers = {} auth_token = mcp_record.get("authorization_token") if auth_token: @@ -2456,6 +2946,7 @@ async def create_agent_run_info( observer=MessageObserver(lang=language), agent_config=agent_config, mcp_host=mcp_host, + mcp_request_timeout_seconds=MCP_REQUEST_TIMEOUT_SECONDS, history=converted_history, stop_event=threading.Event(), mcp_tool_timeout_seconds=RUNTIME_MCP_TOOL_TIMEOUT_SECONDS, @@ -2471,7 +2962,8 @@ async def create_agent_run_info( workspace_path=workspace_path, workspace_run_id=workspace_run_id, tenant_id=tenant_id, - minio_files=minio_files, + minio_files=effective_minio_files, redis_client=get_redis_client(), + user_context=resolve_tool_user_context(agent_config, user_id, tenant_id), ) return agent_run_info diff --git a/backend/agents/nl2agent_agent.py b/backend/agents/nl2agent_agent.py index 0faba3ead3..a04f2bb369 100644 --- a/backend/agents/nl2agent_agent.py +++ b/backend/agents/nl2agent_agent.py @@ -65,6 +65,7 @@ def create_nl2agent_agent_config(language: str) -> AgentConfig: ) return AgentConfig( name=NL2AGENT_NAME, + display_name="创建智能体", description="Ephemeral natural-language agent builder", prompt_templates=None, tools=tools, diff --git a/backend/agents/tool_user_context.py b/backend/agents/tool_user_context.py new file mode 100644 index 0000000000..d3add43bf7 --- /dev/null +++ b/backend/agents/tool_user_context.py @@ -0,0 +1,77 @@ +"""Resolve authenticated caller identity for tool-side authorization.""" + +import logging +from typing import Any + +from consts.const import TENANT_NAME +from nexent.core.agents.agent_model import AgentConfig + +logger = logging.getLogger(__name__) + + +def agent_tree_needs_user_context(agent_config: AgentConfig) -> bool: + """Return whether an agent tree can forward caller identity externally.""" + if not all(hasattr(agent_config, field) for field in ("tools", "external_a2a_agents", "managed_agents")): + return False + return ( + any(tool.source == "mcp" for tool in agent_config.tools) + or bool(agent_config.external_a2a_agents) + or any( + agent_tree_needs_user_context(sub_agent) + for sub_agent in agent_config.managed_agents + ) + ) + + +def build_tool_user_context(user_id: str, tenant_id: str) -> dict[str, Any]: + """Build a tenant-scoped identity snapshot without blocking a run on lookup errors.""" + from database.group_db import query_groups_by_user + from database.tenant_config_db import get_single_config_info + from database.user_tenant_db import get_user_tenant_in_tenant + + user_context: dict[str, Any] = { + "tenant_id": str(tenant_id or ""), + "tenant_name": "", + "user_id": str(user_id or ""), + "user_name": "", + "user_account": "", + "user_groups": [], + } + + try: + tenant_record = get_single_config_info(tenant_id, TENANT_NAME) + user_context["tenant_name"] = str((tenant_record or {}).get("config_value") or "") + except Exception as exc: # noqa: BLE001 + logger.warning("tool user context: tenant name lookup failed: %s", exc) + + try: + user_record = get_user_tenant_in_tenant(user_id, tenant_id) + user_email = str((user_record or {}).get("user_email") or "") + user_context["user_name"] = user_email + user_context["user_account"] = user_email + except Exception as exc: # noqa: BLE001 + logger.warning("tool user context: user email lookup failed: %s", exc) + + try: + current_tenant_id = str(tenant_id or "") + user_context["user_groups"] = [ + str(group["group_name"]) + for group in query_groups_by_user(user_id) or [] + if group.get("group_name") + and str(group.get("tenant_id") or "") == current_tenant_id + ] + except Exception as exc: # noqa: BLE001 + logger.warning("tool user context: user groups lookup failed: %s", exc) + + return user_context + + +def resolve_tool_user_context( + agent_config: AgentConfig, + user_id: str, + tenant_id: str, +) -> dict[str, Any] | None: + """Resolve caller identity only when an MCP or external A2A path can use it.""" + if not agent_tree_needs_user_context(agent_config): + return None + return build_tool_user_context(user_id, tenant_id) diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 24ef6ddb81..7625aa46fb 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -3,12 +3,12 @@ from http import HTTPStatus from typing import Optional -from fastapi import APIRouter, Body, File, Header, HTTPException, Query, Request, UploadFile +from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Query, Request, UploadFile from fastapi.encoders import jsonable_encoder from nexent.core.concurrency import run_blocking from starlette.responses import JSONResponse, Response, StreamingResponse -from consts.const import ASSET_OWNER_TENANT_ID +from consts.const import ASSET_OWNER_TENANT_ID, ENABLE_AGENT_WORKBENCH from consts.model import ( AgentRequest, AgentInfoRequest, @@ -27,22 +27,29 @@ VersionCompareRequest, VersionUpdateRequest, NL2AgentRunRequest, + TagAssignmentFilter, + WorkbenchCapabilityPreviewRequest, ) from consts.exceptions import ( ForbiddenError, SkillDuplicateError, AppException, UnauthorizedError, + TenantResourceLimitError, + tenant_resource_limit_error_payload, ValidationError, RuntimeCapacityExceededError, RuntimeQueueTimeoutError, + WorkbenchConfigVersionConflict, + WorkbenchError, ) +from permissions.depends import require +from permissions.models import CurrentUser from services.asset_owner_visibility import apply_agent_detail_prompt_visibility from management.services.agent.service import ( get_agent_info_impl, get_agent_icon_impl, - get_creating_sub_agent_info_impl, update_agent_info_impl, upload_agent_icon_impl, delete_agent_impl, @@ -51,6 +58,7 @@ check_agent_name_conflict_batch_impl, regenerate_agent_name_batch_impl, list_all_agent_info_impl, + list_agent_page_impl, run_agent_stream, stop_agent_tasks, get_agent_call_relationship_impl, @@ -62,8 +70,17 @@ ) from services.prompt_service import generate_guardrail_rules_impl from services.knowledge_scope_service import get_agent_knowledge_capabilities +from services.workbench_service import ( + build_workbench_capability_preview, + build_workbench_main_profile, +) +from management.services.agent.system_agent_provider import ensure_workbench_main_agent from services.agent_draft_permission_service import AgentDraftEditError from services.nl2agent_service import Nl2AgentDraftSaveError, create_nl2agent_stream +from services.workbench_creation_history_service import ( + prepare_creation_history, + persist_creation_stream, +) from services.agent_version_service import ( publish_version_impl, get_version_list_impl, @@ -86,6 +103,7 @@ agent_runtime_router = APIRouter(prefix="/agent") agent_config_router = APIRouter(prefix="/agent") +require_agent_create_permission = require("agent:create") logger = logging.getLogger("agent_app") @@ -105,6 +123,81 @@ def _runtime_overload_response(exc: Exception) -> JSONResponse: ) +@agent_config_router.get("/workbench/bootstrap") +async def get_workbench_bootstrap_api( + authorization: Optional[str] = Header(None), +): + """Return server-authoritative Workbench modes and creation capabilities.""" + if not ENABLE_AGENT_WORKBENCH: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail={"code": "WORKBENCH_DISABLED"}) + _, tenant_id = get_current_user_id(authorization) + try: + generic_agent = build_workbench_main_profile(tenant_id) + except Exception: + # The feature may be enabled after this tenant was created. Initialize + # lazily if the startup backfill has not reached it yet. + try: + await run_blocking( + "workbench-bootstrap", + ensure_workbench_main_agent, + tenant_id, + "system", + ) + generic_agent = build_workbench_main_profile(tenant_id) + except Exception: + logger.exception( + "Failed to load workbench_main presentation profile for tenant %s", + tenant_id, + ) + generic_agent = { + "display_name": "Nexent Workbench", + "default_skill_resources": [], + } + return { + "code": 0, + "message": "success", + "data": { + "schema_version": 3, + "modes": { + "generic_chat": {"enabled": True}, + "single_agent_chat": {"enabled": True}, + "multi_agent_chat": {"enabled": True}, + "skill_create": {"enabled": False}, + "agent_create": {"enabled": False}, + }, + "generic_agent": generic_agent, + }, + } + + +@agent_config_router.post("/workbench/capabilities/preview") +async def preview_workbench_capabilities_api( + request: WorkbenchCapabilityPreviewRequest, + authorization: Optional[str] = Header(None), +): + """Lock an Agent version and return its published Workbench defaults.""" + if not ENABLE_AGENT_WORKBENCH: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail={"code": "WORKBENCH_DISABLED"}) + user_id, tenant_id = get_current_user_id(authorization) + try: + data = build_workbench_capability_preview( + agent_id=request.agent_id, + version_no=request.version_no, + tenant_id=tenant_id, + user_id=user_id, + ) + return {"code": 0, "message": "success", "data": data} + except WorkbenchError as exc: + raise HTTPException(status_code=exc.status_code, detail={"code": exc.code}) from exc + except ForbiddenError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) from exc + except (ValueError, ValidationError) as exc: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail={"code": "WORKBENCH_RESOURCE_UNAVAILABLE"}, + ) from exc + + @agent_config_router.get("/{agent_id}/knowledge-capabilities") async def get_agent_knowledge_capabilities_api( agent_id: int, @@ -155,6 +248,8 @@ async def agent_run_api( ) except ForbiddenError as e: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) from e + except WorkbenchError as e: + raise HTTPException(status_code=e.status_code, detail={"code": e.code}) from e except ValidationError as e: raise HTTPException( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, @@ -162,6 +257,16 @@ async def agent_run_api( ) from e except (RuntimeCapacityExceededError, RuntimeQueueTimeoutError) as exc: return _runtime_overload_response(exc) + except WorkbenchConfigVersionConflict as e: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail={ + "code": "WORKBENCH_CONFIG_VERSION_CONFLICT", + "current_version": e.current_version, + }, + ) from e + except AppException: + raise except Exception as e: logger.error(f"Agent run error: {str(e)}") # Only expose actual error in debug mode for better diagnosis @@ -207,6 +312,8 @@ async def northbound_agent_run_api( ) from exc except (RuntimeCapacityExceededError, RuntimeQueueTimeoutError) as exc: return _runtime_overload_response(exc) + except AppException: + raise except Exception as exc: logger.error("Northbound agent run error: %s", exc) raise HTTPException( @@ -220,8 +327,11 @@ async def nl2agent_run_api( nl2agent_request: NL2AgentRunRequest, http_request: Request, authorization: Optional[str] = Header(None), + _current_user: CurrentUser = Depends(require_agent_create_permission), ): - """Run one non-persistent NL2Agent turn.""" + """Run NL2Agent; Workbench turns opt into conversation persistence.""" + if (nl2agent_request.persist_history or nl2agent_request.workbench_config is not None) and not ENABLE_AGENT_WORKBENCH: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail={"code": "WORKBENCH_DISABLED"}) try: _, tenant_id, language = get_current_user_info( @@ -233,6 +343,32 @@ async def nl2agent_run_api( language=language, authorization=authorization, ) + if nl2agent_request.persist_history: + user_id, _ = get_current_user_id(authorization) + conversation_id, assistant_index = prepare_creation_history( + conversation_id=nl2agent_request.conversation_id, + mode="agent_create", + query=nl2agent_request.query, + minio_files=nl2agent_request.minio_files, + workbench_config=nl2agent_request.workbench_config, + agent_id=nl2agent_request.agent_id, + user_id=user_id, + tenant_id=tenant_id, + retry_user_message_id=nl2agent_request.retry_user_message_id, + retry_message_index=nl2agent_request.retry_message_index, + ) + stream = persist_creation_stream( + stream, + conversation_id=conversation_id, + assistant_index=assistant_index, + user_id=user_id, + tenant_id=tenant_id, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={"conversation_id": str(conversation_id)}, + ) return StreamingResponse(stream, media_type="text/event-stream") except UnauthorizedError as exc: raise HTTPException( @@ -256,6 +392,11 @@ async def nl2agent_run_api( status_code=HTTPStatus.BAD_REQUEST, detail={"code": exc.code, "message": "Agent context is invalid."}, ) from exc + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid Workbench creation session.", + ) from exc except PermissionError as exc: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, @@ -316,6 +457,11 @@ async def search_agent_info_api( effective_tenant_id = tenant_id or auth_tenant_id agent_info = await get_agent_info_impl(agent_id, effective_tenant_id, version_no, user_id) return apply_agent_detail_prompt_visibility(auth_tenant_id, agent_info) + except ForbiddenError as e: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=str(e), + ) from e except Exception as e: logger.error(f"Agent search info error: {str(e)}") raise HTTPException( @@ -343,19 +489,6 @@ async def get_agent_by_name_api( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Agent not found.") -@agent_config_router.get("/get_creating_sub_agent_id") -async def get_creating_sub_agent_info_api(authorization: Optional[str] = Header(None)): - """ - Create a new sub agent, return agent_ID - """ - try: - return await get_creating_sub_agent_info_impl(authorization) - except Exception as e: - logger.error(f"Agent create error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Agent create error.") - - @agent_config_router.post("/update") async def update_agent_info_api(request: AgentInfoRequest, authorization: Optional[str] = Header(None)): """ @@ -364,6 +497,17 @@ async def update_agent_info_api(request: AgentInfoRequest, authorization: Option try: result = await update_agent_info_impl(request, authorization) return result or {} + except ForbiddenError as exc: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=str(exc), + ) from exc + except TenantResourceLimitError as exc: + logger.warning("Agent update rejected by resource limit: %s", exc) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(exc), + ) except Exception as e: logger.error(f"Agent update error: {str(e)}") raise HTTPException( @@ -495,6 +639,11 @@ async def delete_agent_api( effective_tenant_id = tenant_id or auth_tenant_id await delete_agent_impl(request.agent_id, effective_tenant_id, user_id) return {} + except ForbiddenError as exc: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=str(exc), + ) from exc except Exception as e: logger.error(f"Agent delete error: {str(e)}") raise HTTPException( @@ -520,6 +669,11 @@ async def export_agent_api(request: AgentIDRequest, authorization: Optional[str] } ) return ConversationResponse(code=0, message="success", data=result) + except ForbiddenError as exc: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=str(exc), + ) from exc except Exception as e: logger.error(f"Agent export error: {str(e)}") raise HTTPException( @@ -560,6 +714,12 @@ async def import_agent_api(request: AgentImportRequest, authorization: Optional[ "duplicate_skills": exc.duplicate_names, "skill_conflicts": exc.skill_conflicts, }) + except TenantResourceLimitError as exc: + logger.warning("Agent import rejected by resource limit: %s", exc) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(exc), + ) except Exception as e: logger.error(f"Agent import error: {str(e)}") raise HTTPException( @@ -665,6 +825,67 @@ async def list_all_agent_info_api( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Agent list error.") +@agent_config_router.get("/list/page") +async def list_agent_page_api( + tenant_id: Optional[str] = Query(None, description="Tenant ID for filtering"), + permission: Optional[str] = Query(None, description="EDIT or READ_ONLY"), + tag: Optional[str] = Query(None, description="Exact agent tag"), + search: Optional[str] = Query(None, description="Agent name or description search"), + created_by: Optional[str] = Query(None, description="Exact creator user ID"), + created_by_not: Optional[str] = Query(None, description="Exclude creator user ID"), + tag_predicates: Optional[str] = Query(None, description="Structured tag predicates as JSON"), + search_tag_predicates: Optional[str] = Query(None, description="Text-search tag predicates as JSON"), + page: int = Query(1, ge=1, description="Page number starting from 1"), + page_size: int = Query(20, ge=1, le=100, description="Items per page"), + include_repository_info: bool = Query( + False, description="Include repository listings for agents on this page" + ), + authorization: Optional[str] = Header(None), + request: Request = None, +): + """List visible agents with filters and pagination.""" + try: + user_id, auth_tenant_id, _ = get_current_user_info(authorization, request) + resolved_tenant_id = tenant_id or auth_tenant_id + additional_tenant_id = ( + ASSET_OWNER_TENANT_ID + if tenant_id is None and auth_tenant_id != ASSET_OWNER_TENANT_ID + else None + ) + kwargs = { + "tenant_id": resolved_tenant_id, + "user_id": user_id, + "caller_tenant_id": auth_tenant_id, + "permission": permission, + "tag": tag, + "search": search, + "page": page, + "page_size": page_size, + "include_repository_info": include_repository_info, + } + if created_by: + kwargs["created_by"] = created_by + if created_by_not: + kwargs["created_by_not"] = created_by_not + for key, raw in (("tag_predicates", tag_predicates), ("search_tag_predicates", search_tag_predicates)): + if raw: + parsed = json.loads(raw) + if not isinstance(parsed, list): + raise ValueError(f"{key} must be a list") + kwargs[key] = [TagAssignmentFilter.model_validate(item) for item in parsed] + if additional_tenant_id: + kwargs["additional_tenant_id"] = additional_tenant_id + return await list_agent_page_impl(**kwargs) + except ValueError as error: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(error)) from error + except Exception as error: + logger.error(f"Paged agent list error: {str(error)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Paged agent list error.", + ) from error + + @agent_config_router.get("/call_relationship/{agent_id}") async def get_agent_call_relationship_api(agent_id: int, authorization: Optional[str] = Header(None)): """ diff --git a/backend/apps/agent_evaluation_app.py b/backend/apps/agent_evaluation_app.py index 23e872b0e0..b13a6318b3 100644 --- a/backend/apps/agent_evaluation_app.py +++ b/backend/apps/agent_evaluation_app.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Body, Header, Query, Request from fastapi.responses import JSONResponse, StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StrictInt, TypeAdapter, ValidationError from consts.error_code import ErrorCode from consts.exceptions import AppException, UnauthorizedError @@ -69,6 +69,23 @@ class TrialRunRequest(BaseModel): # New Code Reliability Rating from A to C when flagged as Critical. _AUTH_REQUIRED_MSG = "Authentication required" _UNKNOWN_ID = "" +_AGENT_IDS_ADAPTER = TypeAdapter(list[StrictInt]) + + +def _parse_agent_ids(raw_agent_ids: str | None) -> list[int]: + """Validate an optional JSON list query parameter of agent IDs.""" + if raw_agent_ids is None: + return [] + + try: + agent_ids = _AGENT_IDS_ADAPTER.validate_json(raw_agent_ids) + except ValidationError as exc: + raise AppException( + ErrorCode.COMMON_PARAMETER_INVALID, + "agent_ids must be a JSON list of integers", + ) from exc + + return list(dict.fromkeys(agent_ids)) # ── Endpoints ─────────────────────────────────────────────────────── @@ -165,27 +182,26 @@ async def create_agent_evaluation_api( @router.get("") async def list_agent_evaluations_by_agent_api( - agent_id: int = Query(...), + agent_ids: str | None = Query(None), limit: int = Query(50, ge=0, le=200), offset: int = Query(0, ge=0), authorization: str | None = Header(None), ): - """List evaluation runs belonging to a specific agent (most-recent first). + """List tenant evaluation runs, optionally filtered by agent IDs. - Used by the agent detail page's "Evaluations" tab. Result rows are - pre-sorted by the DB layer and are tenant-scoped: callers never see - rows created by a different tenant even if they can guess the - ``agent_id``. + Result rows are pre-sorted by the DB layer and are tenant-scoped: callers + never see rows created by a different tenant. ``agent_ids`` is an optional + JSON array encoded as one query parameter, such as ``[1,2]``. - ``limit == 0`` requests the FULL result set for the agent (the run - window is bounded by the tenant-level run cap, so this stays small); - any other value is hard-clamped to [1, 200] at the FastAPI level + ``limit == 0`` requests the full result set for the tenant or selected + agents; the run window is bounded by the tenant-level run cap. + Any other value is hard-clamped to [1, 200] at the FastAPI level (``le=200``) so no second clamp is needed inside the handler. """ try: _, tenant_id = get_current_user_id(authorization) data = list_agent_evaluations_by_agent_impl( - agent_id=agent_id, + agent_ids=_parse_agent_ids(agent_ids), tenant_id=tenant_id, limit=limit, offset=offset, @@ -197,9 +213,9 @@ async def list_agent_evaluations_by_agent_api( raise AppException(ErrorCode.COMMON_UNAUTHORIZED, _AUTH_REQUIRED_MSG) except Exception as exc: logger.exception( - "list_agent_evaluations_by_agent_api ERROR: tenant=%s agent_id=%s window=%s..%s err=%r", + "list_agent_evaluations_by_agent_api ERROR: tenant=%s agent_ids=%s window=%s..%s err=%r", _safe_extract_tenant(authorization), - agent_id, + agent_ids, offset, offset + limit, exc, diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py index af73f2a5e3..d4ee50bfb5 100644 --- a/backend/apps/agent_repository_app.py +++ b/backend/apps/agent_repository_app.py @@ -3,31 +3,129 @@ from http import HTTPStatus from typing import Annotated, Optional -from fastapi import APIRouter, Body, Header, HTTPException, Query -from starlette.responses import JSONResponse +from fastapi import APIRouter, Body, File, Header, HTTPException, Query, Request, UploadFile +from starlette.responses import JSONResponse, Response from consts.exceptions import SkillDuplicateError, UnauthorizedError from consts.model import ( AgentRepositoryListingCreateRequest, + KnowledgeBaseResolution, SkillResolution, TagAssignmentFilter, ) from services.agent_repository_service import ( check_repository_import_precheck_impl, create_agent_repository_listing_impl, + delete_official_agent_impl, get_agent_repository_listing_detail_impl, + get_agent_repository_icon_impl, import_agent_from_repository_impl, list_agent_repository_listings_impl, list_agent_repository_tag_stats_impl, list_my_editable_agents_impl, + list_official_agent_management_impl, update_agent_repository_status_impl, + upload_agent_repository_icon_impl, ) -from utils.auth_utils import get_current_user_id +from services.official_agent_sync_service import sync_official_agents +from utils.auth_utils import get_current_user_context, get_current_user_id +from utils.agent_transfer_utils import AgentToolImportError logger = logging.getLogger(__name__) agent_repository_router = APIRouter(prefix="/repository/agent") +@agent_repository_router.post("/{agent_id}/versions/{version_no}/icon") +async def upload_agent_repository_icon_api( + agent_id: int, + version_no: int, + file: UploadFile = File(...), + authorization: str = Header(None), +): + try: + user_id, tenant_id = get_current_user_id(authorization) + result = await upload_agent_repository_icon_impl( + agent_id, version_no, tenant_id, user_id, await file.read() + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) from exc + except UnauthorizedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) from exc + + +@agent_repository_router.get("/{agent_id}/versions/{version_no}/icon/{image_id}") +async def get_agent_repository_icon_api( + agent_id: int, + version_no: int, + image_id: str, + authorization: str = Header(None), +): + try: + _, tenant_id = get_current_user_id(authorization) + content, content_type = get_agent_repository_icon_impl( + agent_id, version_no, image_id, tenant_id + ) + return Response(content=content, media_type=content_type) + except FileNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) from exc + except UnauthorizedError as exc: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) from exc + + +def _require_super_admin(user_role: str) -> None: + if user_role.upper() != "SU": + raise UnauthorizedError("Super admin role is required") + + +@agent_repository_router.get("/official/management") +async def list_official_agent_management_api( + authorization: str = Header(None) +): + try: + _, _, user_role = get_current_user_context(authorization) + _require_super_admin(user_role) + return JSONResponse(content={"items": list_official_agent_management_impl()}) + except UnauthorizedError as error: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(error)) + + +@agent_repository_router.delete("/official/management/{agent_repository_id}") +async def delete_official_agent_api( + agent_repository_id: int, authorization: str = Header(None) +): + try: + user_id, _, user_role = get_current_user_context(authorization) + _require_super_admin(user_role) + return JSONResponse(content=delete_official_agent_impl(agent_repository_id, user_id)) + except UnauthorizedError as error: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(error)) + except ValueError as error: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(error)) + + +@agent_repository_router.post("/internal/official/sync") +async def sync_official_agents_api( + request: Request, + profiles: Optional[str] = Query(None), +): + """Synchronize mounted official bundles from a container-local request.""" + client_host = request.client.host if request.client else None + if client_host not in {"127.0.0.1", "::1"}: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Official agent synchronization is only available locally", + ) + try: + kwargs = {} + if profiles is not None: + kwargs["profiles"] = profiles + result = await sync_official_agents(**kwargs) + return JSONResponse(content={"synchronized": len(result), "items": result}) + except (OSError, ValueError) as error: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(error)) + + def _parse_tag_predicates(raw: str | None) -> list[TagAssignmentFilter]: if not raw: return [] @@ -257,7 +355,7 @@ async def create_agent_repository_listing_api( """Create or update a marketplace repository listing from an agent version snapshot.""" try: user_id, tenant_id = get_current_user_id(authorization) - card_fields = payload.model_dump(exclude_none=True) if payload else None + card_fields = payload.model_dump(exclude_unset=True) if payload else None result = await create_agent_repository_listing_impl( agent_id=agent_id, tenant_id=tenant_id, @@ -310,19 +408,42 @@ async def check_repository_import_precheck_api( @agent_repository_router.post("/{agent_repository_id}/import") async def import_agent_from_repository_api( agent_repository_id: int, - skill_resolutions: Optional[list[SkillResolution]] = Body(default=None), + payload: Optional[object] = Body(default=None), authorization: Optional[str] = Header(None), ): """Import an agent tree from a marketplace repository listing into the current tenant.""" try: - _, tenant_id = get_current_user_id(authorization) - await import_agent_from_repository_impl( + user_id, tenant_id = get_current_user_id(authorization) + skill_resolutions = None + model_ids = None + embedding_model_ids = None + knowledge_base_resolutions = None + if isinstance(payload, list): + skill_resolutions = [SkillResolution.model_validate(item) for item in payload] + elif isinstance(payload, dict): + skill_resolutions = [ + SkillResolution.model_validate(item) + for item in (payload.get("skill_resolutions") or []) + ] or None + model_ids = payload.get("model_ids") + embedding_model_ids = payload.get("embedding_model_ids") + knowledge_base_resolutions = [ + KnowledgeBaseResolution.model_validate(item) + for item in (payload.get("knowledge_base_resolutions") or []) + ] or None + + result = await import_agent_from_repository_impl( agent_repository_id=agent_repository_id, tenant_id=tenant_id, authorization=authorization, skill_resolutions=skill_resolutions, + model_ids=model_ids, + embedding_model_ids=embedding_model_ids, + knowledge_base_resolutions=knowledge_base_resolutions, + user_id=user_id, + return_root_id=True, ) - return JSONResponse(status_code=HTTPStatus.OK, content={}) + return JSONResponse(status_code=HTTPStatus.OK, content=result) except UnauthorizedError as e: logger.warning( f"Unauthorized agent repository import attempt " @@ -341,6 +462,9 @@ async def import_agent_from_repository_api( "duplicate_skills": exc.duplicate_names, }, ) + except AgentToolImportError as e: + logger.warning("Agent repository tool validation failed (id=%s): %s", agent_repository_id, e) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except ValueError as e: logger.warning( f"Agent repository listing not found for import " diff --git a/backend/apps/api_key_app.py b/backend/apps/api_key_app.py index 8e572baf6c..8bcecc19b2 100644 --- a/backend/apps/api_key_app.py +++ b/backend/apps/api_key_app.py @@ -2,9 +2,9 @@ import logging from http import HTTPStatus -from typing import Optional +from typing import Any, Dict, Optional -from fastapi import APIRouter, Header, HTTPException, Query +from fastapi import APIRouter, Header, HTTPException, Query, Request from fastapi.responses import JSONResponse from consts.exceptions import ( @@ -19,6 +19,7 @@ refresh_user_api_key, revoke_user_api_keys, ) +from services.audit_service import record_security_event from utils.auth_utils import get_current_user_context logger = logging.getLogger("api_key_app") @@ -37,6 +38,19 @@ def _map_error(exc: Exception) -> None: raise exc +def _audit_safe_target(result: Dict[str, Any]) -> Dict[str, Any]: + """Pick non-secret target fields for the audit trail. + + The service results carry the freshly created plaintext API key; only the + whitelisted identifiers below are handed to the audit entry. + """ + return { + "target_user_id": (result or {}).get("user_id"), + "target_email": (result or {}).get("email"), + "revoked_count": (result or {}).get("revoked_count"), + } + + @router.get("") async def list_api_keys_endpoint( tenant_id: str = Query(...), @@ -66,6 +80,7 @@ async def list_api_keys_endpoint( @router.post("/refresh") async def refresh_api_key_endpoint( payload: ApiKeyTargetRequest, + http_request: Request, authorization: Optional[str] = Header(None), ) -> JSONResponse: try: @@ -77,6 +92,9 @@ async def refresh_api_key_endpoint( user_id=payload.user_id, email=str(payload.email) if payload.email else None, ) + record_security_event("api_key_refresh", request=http_request, + user_id=actor_user_id, tenant_id=tenant_id, + details=_audit_safe_target(result)) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "success", "data": result} ) @@ -87,6 +105,7 @@ async def refresh_api_key_endpoint( @router.delete("") async def revoke_api_key_endpoint( + http_request: Request, user_id: Optional[str] = Query(None), email: Optional[str] = Query(None), authorization: Optional[str] = Header(None), @@ -101,6 +120,9 @@ async def revoke_api_key_endpoint( user_id=target.user_id, email=str(target.email) if target.email else None, ) + record_security_event("api_key_revoke", request=http_request, + user_id=actor_user_id, tenant_id=tenant_id, + details=_audit_safe_target(result)) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "success", "data": result} ) diff --git a/backend/apps/cas_app.py b/backend/apps/cas_app.py index a2da203915..5fefed290f 100644 --- a/backend/apps/cas_app.py +++ b/backend/apps/cas_app.py @@ -7,7 +7,10 @@ from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse -from consts.exceptions import TenantResourceLimitError +from consts.exceptions import ( + TenantResourceLimitError, + tenant_resource_limit_error_payload, +) from services.cas_service import ( CAS_SERVER_URL, @@ -19,6 +22,7 @@ renew_with_ticket, revoke_from_logout_request, ) +from services.audit_service import record_security_event logger = logging.getLogger(__name__) router = APIRouter(prefix="/user/cas", tags=["cas"]) @@ -43,9 +47,13 @@ async def login(redirect: str = Query("/", description="URL to return to after l @router.get("/callback") -async def callback(ticket: str = "", redirect: str = "/"): +async def callback(http_request: Request, ticket: str = "", redirect: str = "/"): try: result = await login_with_ticket(ticket, redirect) + result_user = (result or {}).get("user") or {} + record_security_event("cas_login", request=http_request, + user_id=result_user.get("id"), + user_email=result_user.get("email")) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "CAS login successful", "data": result}, @@ -55,7 +63,10 @@ async def callback(ticket: str = "", redirect: str = "/"): raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="CAS authentication failed") except TenantResourceLimitError as exc: logger.warning("CAS callback rejected by tenant resource limit: %s", exc) - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(exc), + ) except Exception as exc: logger.error(f"CAS callback failed: {exc}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="CAS login failed") @@ -112,6 +123,11 @@ async def _handle_logout_request( ) result = revoke_from_logout_request(logout_request) logger.info("CAS SLO %s revoke result: %s", endpoint, result) + record_security_event("cas_logout", request=request, + details={"endpoint": endpoint, + "revoked": (result or {}).get("revoked"), + "cas_user_id": (result or {}).get("cas_user_id", ""), + "session_index": (result or {}).get("session_index", "")}) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "success", "data": result}, diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index a97551cd5d..d1d50b55fb 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -60,6 +60,7 @@ AIDP_API_KEY, AIDP_SERVER_URL, ENABLE_AIDP_KNOWLEDGE, + ENABLE_AGENT_WORKBENCH, IS_SPEED_MODE, RUNTIME_THREAD_SHUTDOWN_GRACE_SECONDS, ) @@ -81,6 +82,7 @@ async def recover_config_tasks_on_startup(): from services.startup_recovery_service import ( recover_config_tasks, schedule_interrupted_upload_cleanup, + schedule_workbench_main_backfill, ) await config_thread_manager.run( @@ -93,6 +95,8 @@ async def recover_config_tasks_on_startup(): ) start_eval_maintenance(config_thread_manager) await schedule_interrupted_upload_cleanup(CONFIG_SERVICE_NAME) + if ENABLE_AGENT_WORKBENCH: + schedule_workbench_main_backfill() async def sync_default_prompt_template_on_startup(): diff --git a/backend/apps/conversation_management_app.py b/backend/apps/conversation_management_app.py index 8bbd3693af..295d98fab4 100644 --- a/backend/apps/conversation_management_app.py +++ b/backend/apps/conversation_management_app.py @@ -3,10 +3,12 @@ from typing import Annotated, Any, Dict, Optional from fastapi import APIRouter, Header, HTTPException, Query, Request +from consts.const import ENABLE_AGENT_WORKBENCH from consts.model import ( BatchDeleteConversationRequest, ConversationKnowledgeScopeUpdateRequest, + WorkbenchConfigUpdateRequest, ConversationRequest, ConversationResponse, GenerateTitleRequest, @@ -14,7 +16,14 @@ OpinionRequest, RenameRequest, ) -from consts.exceptions import ConversationNotFoundError, ValidationError, TokenExpiredError +from consts.exceptions import ( + AppException, + ConversationNotFoundError, + ValidationError, + TokenExpiredError, + WorkbenchConfigVersionConflict, + WorkbenchError, +) from database.conversation_db import get_conversation_list_page from services.conversation_management_service import ( create_new_conversation, @@ -25,6 +34,7 @@ get_sources_service, rename_conversation_service, update_conversation_knowledge_scope_service, + update_conversation_workbench_config_service, update_message_opinion_service, get_message_id_by_index_impl, ) from utils.auth_utils import get_current_user_id, get_current_user_info @@ -56,6 +66,8 @@ async def create_new_conversation_endpoint(request: ConversationRequest, authori except TokenExpiredError as e: logging.warning("Session expired") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except AppException: + raise except Exception as e: logging.error(f"Failed to create conversation: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) @@ -68,6 +80,7 @@ async def list_conversations_endpoint( authorization: Optional[str] = Header(None), offset: Annotated[int, Query(ge=0)] = 0, limit: Annotated[Optional[int], Query(ge=1, le=100)] = None, + conversation_type: Annotated[Optional[str], Query(pattern="^(agent_chat|workbench)$")] = None, ): """ Get all conversation list @@ -88,6 +101,7 @@ async def list_conversations_endpoint( week_start_ms=week_start_ms, limit=limit, offset=offset, + **({"conversation_type": conversation_type} if conversation_type is not None else {}), ) return ConversationResponse(code=0, message="success", data=conversations) except TokenExpiredError as e: @@ -209,11 +223,15 @@ async def update_conversation_knowledge_scope_endpoint( try: user_id, tenant_id = get_current_user_id(authorization) scope = request.scope.model_dump(mode="json") if request.scope is not None else None + version_kwargs = {} + if request.expected_workbench_config_version is not None: + version_kwargs["expected_workbench_config_version"] = request.expected_workbench_config_version result = update_conversation_knowledge_scope_service( conversation_id=conversation_id, knowledge_scope=scope, user_id=user_id, tenant_id=tenant_id, + **version_kwargs, ) return ConversationResponse( code=0, @@ -222,6 +240,11 @@ async def update_conversation_knowledge_scope_endpoint( ) except ConversationNotFoundError as exc: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) from exc + except WorkbenchConfigVersionConflict as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail={ + "code": "WORKBENCH_CONFIG_VERSION_CONFLICT", + "current_version": exc.current_version, + }) from exc except ValidationError as exc: raise HTTPException( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, @@ -237,6 +260,41 @@ async def update_conversation_knowledge_scope_endpoint( raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)) from exc +@router.patch("/{conversation_id}/workbench-config", response_model=ConversationResponse) +async def update_conversation_workbench_config_endpoint( + conversation_id: int, + request: WorkbenchConfigUpdateRequest, + authorization: Optional[str] = Header(None), +): + """Replace canonical Workbench config using its independent optimistic lock.""" + if not ENABLE_AGENT_WORKBENCH: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail={"code": "WORKBENCH_DISABLED"}) + + try: + user_id, _ = get_current_user_id(authorization) + result = update_conversation_workbench_config_service( + conversation_id=conversation_id, + config=request.config.model_dump(mode="json"), + expected_version=request.expected_version, + user_id=user_id, + ) + return ConversationResponse(code=0, message="success", data=result) + except ConversationNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) from exc + except WorkbenchConfigVersionConflict as exc: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail={ + "code": "WORKBENCH_CONFIG_VERSION_CONFLICT", + "current_version": exc.current_version, + }, + ) from exc + except WorkbenchError as exc: + raise HTTPException(status_code=exc.status_code, detail={"code": exc.code}) from exc + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail={"code": "WORKBENCH_CONFIG_INVALID"}) from exc + + @router.post("/sources", response_model=Dict[str, Any]) async def get_sources_endpoint(request: Dict[str, Any], authorization: Optional[str] = Header(None)): """ diff --git a/backend/apps/data_process_app.py b/backend/apps/data_process_app.py index 5485ae5dba..4217a4218b 100644 --- a/backend/apps/data_process_app.py +++ b/backend/apps/data_process_app.py @@ -13,6 +13,7 @@ TaskRequest, ) from data_process.tasks import process_sync, submit_process_forward_chain +from data_process.utils import get_task_details as get_task_details_util from services.data_process_service import get_data_process_service @@ -235,7 +236,7 @@ async def get_index_tasks(index_name: str): @router.get("/{task_id}/details") async def get_task_details(task_id: str): """Get detailed information about a task, including results""" - task = await service.get_task_details(task_id) + task = await get_task_details_util(task_id) if not task: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Task not found") diff --git a/backend/apps/evaluation_set_app.py b/backend/apps/evaluation_set_app.py index 8e274f9b33..92d1e3337b 100644 --- a/backend/apps/evaluation_set_app.py +++ b/backend/apps/evaluation_set_app.py @@ -1,16 +1,18 @@ import io -import json import logging from http import HTTPStatus from typing import Any from urllib.parse import quote -from fastapi import APIRouter, Body, File, Form, Header, Query, Request, UploadFile +from fastapi import APIRouter, Body, File, Form, Header, Query, UploadFile from fastapi.responses import JSONResponse, StreamingResponse -from pydantic import BaseModel, Field - from nexent.core.concurrency import ManagedTaskSpec +from pydantic import BaseModel, Field +from consts.const import ( + MAX_EVALUATION_SET_FILE_SIZE_BYTES, + MAX_EVALUATION_SET_FILE_SIZE_MB, +) from consts.error_code import ErrorCode from consts.evaluation_limits import ( CASE_ANSWER_MAX_LEN, @@ -71,13 +73,12 @@ class BatchDeleteRequest(BaseModel): case_ids: list[int] -MAX_DOCX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB - - class GenerateCasesRequest(BaseModel): description: str = Field(..., min_length=1, max_length=1000) count: int = Field(default=20, ge=1, le=200) model_id: int = Field(...) + # ES mode: knowledge-base display names. With ENABLE_AIDP_KNOWLEDGE=true: + # AIDP kds_ids. The backend interprets the values by that env switch. knowledge_base_names: list[str] | None = None agent_id: int | None = None agent_version_no: int | None = None @@ -86,15 +87,23 @@ class GenerateCasesRequest(BaseModel): target_set_id: int | None = None -def _parse_docx_to_text(raw: bytes) -> str: - """Extract text content from a .docx file.""" - from io import BytesIO - - from docx import Document - - doc = Document(BytesIO(raw)) - paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] - return "\n\n".join(paragraphs) +def _validate_evaluation_set_file_size(raw: bytes, filename: str | None) -> None: + """Reject an evaluation-set file before parsing when it exceeds the limit.""" + actual_bytes = len(raw) + if actual_bytes <= MAX_EVALUATION_SET_FILE_SIZE_BYTES: + return + + raise AppException( + ErrorCode.FILE_TOO_LARGE, + f"Evaluation set file exceeds the maximum size of {MAX_EVALUATION_SET_FILE_SIZE_MB} MB", + details={ + "resource": "evaluation_set_file", + "filename": filename or "unknown", + "limit_mb": MAX_EVALUATION_SET_FILE_SIZE_MB, + "limit_bytes": MAX_EVALUATION_SET_FILE_SIZE_BYTES, + "actual_bytes": actual_bytes, + }, + ) # ── Endpoints ─────────────────────────────────────────────────────── @@ -198,6 +207,7 @@ async def upload_evaluation_set_api( ErrorCode.COMMON_VALIDATION_ERROR, f"Unsupported file type: {filename}. Only .xlsx and .xls are accepted.", ) + _validate_evaluation_set_file_size(raw, filename) source_filenames.append(filename) cases = parse_evaluation_cases_from_excel(filename=filename, raw=raw) all_cases.extend(cases) @@ -239,6 +249,15 @@ async def download_evaluation_set_template_api(): ) +@router.get("/config") +async def evaluation_set_config_api( + authorization: str | None = Header(None), +): + """Return effective evaluation-set upload limits for the web client.""" + get_current_user_id(authorization) + return _ok({"max_file_size_mb": MAX_EVALUATION_SET_FILE_SIZE_MB}) + + @router.get("/{evaluation_set_id}") async def get_evaluation_set_api( evaluation_set_id: int, @@ -452,49 +471,6 @@ async def delete_evaluation_set_api( ) -async def _parse_generate_cases_request( - request: Request, -) -> tuple[GenerateCasesRequest, UploadFile | None]: - """Parse the request body as JSON or multipart form. - - Returns ``(payload, file)`` where *file* is ``None`` for JSON bodies. - """ - content_type = request.headers.get("content-type", "") - if "multipart" in content_type: - form = await request.form() - payload = GenerateCasesRequest(**json.loads(str(form["payload"]))) - file = form.get("file") - else: - body = await request.json() - payload = GenerateCasesRequest(**body) - file = None - return payload, file - - -def _validate_and_parse_docx(raw: bytes, filename: str | None) -> tuple[str, str]: - """Validate extension and size, then parse a DOCX upload. - - Returns ``(file_content, file_name)``. Raises ``AppException`` when the - extension is invalid, the file is too large, or parsing fails. - """ - if not filename or not filename.lower().endswith(".docx"): - raise AppException( - ErrorCode.COMMON_VALIDATION_ERROR, "Only .docx files are supported" - ) - if len(raw) > MAX_DOCX_FILE_SIZE: - raise AppException( - ErrorCode.COMMON_VALIDATION_ERROR, - f"File size exceeds {MAX_DOCX_FILE_SIZE // (1024 * 1024)}MB limit", - ) - try: - file_content = _parse_docx_to_text(raw) - except Exception as e: - raise AppException( - ErrorCode.COMMON_VALIDATION_ERROR, f"Failed to parse DOCX file: {e}" - ) from e - return file_content, filename - - def _resolve_target_set( payload: GenerateCasesRequest, tenant_id: str, @@ -534,20 +510,13 @@ def _resolve_target_set( @router.post("/generate-cases-async") async def generate_cases_async_api( - request: Request, + payload: GenerateCasesRequest, authorization: str | None = Header(None), ): + """Start async AI case generation for a new or existing evaluation set.""" try: user_id, tenant_id = get_current_user_id(authorization) - payload, file = await _parse_generate_cases_request(request) - - file_content = None - file_name = None - if file and isinstance(file, UploadFile): - raw = await file.read() - file_content, file_name = _validate_and_parse_docx(raw, file.filename) - set_id, is_new = _resolve_target_set(payload, tenant_id, user_id) _update_generation_status(set_id, tenant_id, "GENERATING", 0) @@ -564,8 +533,6 @@ async def generate_cases_async_api( payload.description, payload.count, payload.model_id, - file_content, - file_name, payload.agent_id, is_new, payload.knowledge_base_names, diff --git a/backend/apps/file_management_app.py b/backend/apps/file_management_app.py index 8bfbf608c3..af6bd900b6 100644 --- a/backend/apps/file_management_app.py +++ b/backend/apps/file_management_app.py @@ -3,7 +3,7 @@ import re from datetime import datetime from http import HTTPStatus -from typing import Annotated, List, Optional +from typing import Annotated, List, Literal, Optional from urllib.parse import quote, unquote, urlparse, urlunparse import httpx @@ -115,7 +115,7 @@ async def options_route(full_path: str): @file_management_config_router.post("/upload") async def upload_files( file: List[UploadFile] = File(..., alias="file"), - destination: str = Form(..., + destination: Literal["local", "minio"] = Form(..., description="Upload destination: 'local' or 'minio'"), folder: str = Form( "attachments", description="Storage folder path for MinIO (optional)"), @@ -194,10 +194,10 @@ async def upload_files( ) except HTTPException: raise - except QuotaExceededError: - raise except AppException: raise + except QuotaExceededError: + raise except Exception as e: logger.error(f"File upload error: {str(e)}") raise HTTPException( diff --git a/backend/apps/group_app.py b/backend/apps/group_app.py index 10f06be024..9f8250a4ed 100644 --- a/backend/apps/group_app.py +++ b/backend/apps/group_app.py @@ -4,7 +4,7 @@ import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Header +from fastapi import APIRouter, Header, HTTPException, Request from http import HTTPStatus from starlette.responses import JSONResponse @@ -13,7 +13,14 @@ GroupUserRequest, GroupListRequest, SetDefaultGroupRequest, GroupMembersUpdateRequest ) -from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError +from consts.exceptions import ( + NotFoundException, + TenantResourceLimitError, + UnauthorizedError, + ValidationError, + tenant_resource_limit_error_payload, +) +from services.audit_service import record_security_event from services.group_service import ( create_group, get_group_info, update_group, delete_group, add_user_to_single_group, remove_user_from_single_group, get_group_users, @@ -30,6 +37,7 @@ @router.post("", response_model=None) async def create_group_endpoint( request: GroupCreateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -44,7 +52,7 @@ async def create_group_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Create group group_info = create_group( @@ -56,6 +64,10 @@ async def create_group_endpoint( logger.info(f"Created group '{request.group_name}' in tenant {request.tenant_id} by user {user_id}") + record_security_event("group_create", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"tenant_id": request.tenant_id, + "group_name": request.group_name}) return JSONResponse( status_code=HTTPStatus.CREATED, content={ @@ -70,6 +82,12 @@ async def create_group_endpoint( status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc) ) + except TenantResourceLimitError as exc: + logger.warning("Group creation rejected by resource limit: %s", exc) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(exc), + ) except ValidationError as exc: logger.warning(f"Group creation validation error: {str(exc)}") raise HTTPException( @@ -193,6 +211,7 @@ async def get_groups_endpoint( async def update_group_endpoint( group_id: int, request: GroupUpdateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -208,7 +227,7 @@ async def update_group_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Prepare updates dict updates = {} @@ -232,6 +251,10 @@ async def update_group_endpoint( logger.info(f"Updated group {group_id} by user {user_id}") + record_security_event("group_update", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"group_id": group_id, + "updated_fields": sorted(updates)}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -268,6 +291,7 @@ async def update_group_endpoint( @router.delete("/{group_id}") async def delete_group_endpoint( group_id: int, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -282,7 +306,7 @@ async def delete_group_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Delete group success = delete_group( @@ -295,6 +319,9 @@ async def delete_group_endpoint( logger.info(f"Deleted group {group_id} by user {user_id}") + record_security_event("group_delete", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"group_id": group_id}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -332,6 +359,7 @@ async def delete_group_endpoint( async def add_user_to_group_endpoint( group_id: int, request: GroupUserRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -351,7 +379,7 @@ async def add_user_to_group_endpoint( raise ValidationError("group_ids should not be provided for single group operation") # Get current user ID from token - current_user_id, _ = get_current_user_id(authorization) + current_user_id, operator_tenant_id = get_current_user_id(authorization) # Add user to group result = add_user_to_single_group( @@ -362,6 +390,10 @@ async def add_user_to_group_endpoint( logger.info(f"Added user {request.user_id} to group {group_id} by user {current_user_id}") + record_security_event("group_member_add", request=http_request, + user_id=current_user_id, tenant_id=operator_tenant_id, + details={"target_user_id": request.user_id, + "group_id": group_id}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -400,6 +432,7 @@ async def add_user_to_group_endpoint( async def remove_user_from_group_endpoint( group_id: int, user_id: str, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -415,7 +448,7 @@ async def remove_user_from_group_endpoint( """ try: # Get current user ID from token - current_user_id, _ = get_current_user_id(authorization) + current_user_id, operator_tenant_id = get_current_user_id(authorization) # Remove user from group success = remove_user_from_single_group( @@ -429,6 +462,10 @@ async def remove_user_from_group_endpoint( logger.info(f"Removed user {user_id} from group {group_id} by user {current_user_id}") + record_security_event("group_member_remove", request=http_request, + user_id=current_user_id, tenant_id=operator_tenant_id, + details={"target_user_id": user_id, + "group_id": group_id}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -504,6 +541,7 @@ async def get_group_users_endpoint(group_id: int) -> JSONResponse: async def update_group_members_endpoint( group_id: int, request: GroupMembersUpdateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -519,7 +557,7 @@ async def update_group_members_endpoint( """ try: # Get current user ID from token - current_user_id, _ = get_current_user_id(authorization) + current_user_id, operator_tenant_id = get_current_user_id(authorization) # Update group members result = update_group_members( @@ -530,6 +568,11 @@ async def update_group_members_endpoint( logger.info(f"Updated group {group_id} members by user {current_user_id}: {result}") + record_security_event("group_member_update", request=http_request, + user_id=current_user_id, tenant_id=operator_tenant_id, + details={"group_id": group_id, + "user_count": len(request.user_ids), + "user_ids": request.user_ids[:20]}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -567,6 +610,7 @@ async def update_group_members_endpoint( @router.post("/members/batch") async def add_user_to_groups_endpoint( request: GroupUserRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -585,7 +629,7 @@ async def add_user_to_groups_endpoint( raise ValidationError("group_ids is required for batch operations") # Get current user ID from token - current_user_id, _ = get_current_user_id(authorization) + current_user_id, operator_tenant_id = get_current_user_id(authorization) # Add user to multiple groups results = add_user_to_groups( @@ -596,6 +640,11 @@ async def add_user_to_groups_endpoint( logger.info(f"Batch added user {request.user_id} to {len(request.group_ids)} groups by user {current_user_id}") + record_security_event("group_member_batch_add", request=http_request, + user_id=current_user_id, tenant_id=operator_tenant_id, + details={"target_user_id": request.user_id, + "group_count": len(request.group_ids), + "group_ids": request.group_ids[:20]}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -663,6 +712,7 @@ async def get_tenant_default_group_endpoint(tenant_id: str) -> JSONResponse: async def set_tenant_default_group_endpoint( tenant_id: str, request: SetDefaultGroupRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -678,7 +728,7 @@ async def set_tenant_default_group_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Set default group ID success = set_tenant_default_group_id( @@ -692,6 +742,10 @@ async def set_tenant_default_group_endpoint( logger.info(f"Set default group {request.default_group_id} for tenant {tenant_id} by user {user_id}") + record_security_event("group_default_set", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"tenant_id": tenant_id, + "default_group_id": request.default_group_id}) return JSONResponse( status_code=HTTPStatus.OK, content={ diff --git a/backend/apps/invitation_app.py b/backend/apps/invitation_app.py index 55bbac9983..acbc9f48f5 100644 --- a/backend/apps/invitation_app.py +++ b/backend/apps/invitation_app.py @@ -4,7 +4,7 @@ import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Header +from fastapi import APIRouter, Header, HTTPException, Request from http import HTTPStatus from starlette.responses import JSONResponse @@ -12,6 +12,7 @@ InvitationCreateRequest, InvitationUpdateRequest, InvitationListRequest ) from consts.exceptions import NotFoundException, ValidationError, UnauthorizedError, DuplicateError +from services.audit_service import record_security_event from services.invitation_service import ( create_invitation_code, update_invitation_code, get_invitation_by_code, check_invitation_available, use_invitation_code, update_invitation_code_status, @@ -86,6 +87,7 @@ async def list_invitations_endpoint( @router.post("") async def create_invitation_endpoint( request: InvitationCreateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -100,7 +102,7 @@ async def create_invitation_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Validate tenant_id from request tenant_id = request.tenant_id @@ -123,6 +125,13 @@ async def create_invitation_endpoint( logger.info(f"Created invitation code {invitation_info['invitation_code']} (type: {request.code_type}) for tenant {tenant_id} by user {user_id}") + record_security_event("invitation_create", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"invitation_code": (invitation_info or {}).get("invitation_code"), + "code_type": request.code_type, + "tenant_id": request.tenant_id, + "capacity": request.capacity, + "group_ids": (request.group_ids or [])[:20]}) return JSONResponse( status_code=HTTPStatus.CREATED, content={ @@ -173,6 +182,7 @@ async def create_invitation_endpoint( async def update_invitation_endpoint( invitation_code: str, request: InvitationUpdateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -188,7 +198,7 @@ async def update_invitation_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Get invitation info to find invitation_id invitation_info = get_invitation_by_code(invitation_code) @@ -221,6 +231,10 @@ async def update_invitation_endpoint( logger.info(f"Updated invitation code {invitation_code} by user {user_id}") + record_security_event("invitation_update", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"invitation_code": invitation_code, + "updates": updates}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -334,6 +348,7 @@ async def check_invitation_code_endpoint(invitation_code: str) -> JSONResponse: @router.delete("/{invitation_code}") async def delete_invitation_endpoint( invitation_code: str, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -348,7 +363,7 @@ async def delete_invitation_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Get invitation info to find invitation_id invitation_info = get_invitation_by_code(invitation_code) @@ -368,6 +383,9 @@ async def delete_invitation_endpoint( logger.info(f"Deleted invitation code {invitation_code} by user {user_id}") + record_security_event("invitation_delete", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"invitation_code": invitation_code}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -438,6 +456,7 @@ async def check_invitation_available_endpoint(invitation_code: str) -> JSONRespo @router.post("/{invitation_code}/use") async def use_invitation_endpoint( invitation_code: str, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -452,7 +471,7 @@ async def use_invitation_endpoint( """ try: # Get current user ID from token - current_user_id, _ = get_current_user_id(authorization) + current_user_id, operator_tenant_id = get_current_user_id(authorization) # Users can use invitation codes for themselves @@ -464,6 +483,9 @@ async def use_invitation_endpoint( logger.info(f"User {current_user_id} used invitation code {invitation_code}") + record_security_event("invitation_use", request=http_request, + user_id=current_user_id, tenant_id=operator_tenant_id, + details={"invitation_code": invitation_code}) return JSONResponse( status_code=HTTPStatus.OK, content={ diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index 2c02edcef1..984f38ce76 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -7,9 +7,12 @@ - Map domain/service exceptions to HTTP where necessary; avoid leaking internals. - Return structured responses consistent with existing patterns for backward compatibility. -Authorization: The bearer token is retrieved via the `authorization` header and -parsed with `utils.auth_utils.get_current_user_id`, then propagated as `user_id` -and `tenant_id` to services/database helpers. +Authorization: Mutating endpoints require RBAC permissions (model:create / +model:update / model:delete) via ``permissions.depends.require``; read endpoints +require ``model:read``. Cross-tenant ``/manage/*`` endpoints additionally +require the SU role, or the ADMIN role when the targeted tenant is the +caller's own. Identity is resolved from the bearer token into a +``CurrentUser`` and propagated as ``user_id`` / ``tenant_id`` to services. """ import asyncio @@ -18,6 +21,7 @@ from consts.model import ( BatchCreateModelsRequest, + BackfillDefaultsRequest, CapacitySuggestionFields, ModelRequest, ModelProbeRequest, @@ -37,7 +41,7 @@ ) from consts.const import CAPACITY_SUGGESTION_ENABLED -from fastapi import APIRouter, Header, Query, HTTPException +from fastapi import APIRouter, Depends, Header, Query, HTTPException from fastapi.responses import JSONResponse from fastapi.encoders import jsonable_encoder from http import HTTPStatus @@ -61,12 +65,28 @@ get_capacity_coverage, pop_capacity_accept_signal, _record_capacity_suggestion_accept, + get_model_reasoning_capability, + _ids_for_created_models, + _backfill_default_model_slots, ) +from permissions.depends import authenticate, require +from permissions.models import CurrentUser from utils.auth_utils import get_current_user_id from consts.exceptions import TokenExpiredError from nexent.core.concurrency import run_blocking from database.model_management_db import get_model_by_model_id +# Permission strings normalized by backend RBAC cache (lower-case type:subtype). +MODEL_CREATE_PERMISSION = "model:create" +MODEL_READ_PERMISSION = "model:read" +MODEL_UPDATE_PERMISSION = "model:update" +MODEL_DELETE_PERMISSION = "model:delete" +# Roles allowed on the cross-tenant /manage/* endpoints. ADMIN shares the same +# MODEL permission seeds as SU, so permission strings cannot separate the two +# and the role itself must be checked. ADMIN is scoped to its own tenant by +# ``_require_manage_scope``; SU may target any tenant. +_MANAGE_ALLOWED_ROLES = ("SU", "ADMIN") + # Model Catalog loader (with graceful fallback) try: from configs.model_catalog_loader import ( @@ -133,6 +153,28 @@ def _log_safe(value: Any) -> str: return _LOG_UNSAFE_CHARS.sub("", str(value)) +def _require_manage_scope(current_user: CurrentUser, target_tenant_id: str) -> None: + """Authorize a /manage/* call against the tenant it targets. + + SU may manage any tenant. ADMIN may manage only the tenant its token + belongs to -- the tenant-resource page always passes the caller's own + tenant_id, so restricting ADMIN outright would break tenant admins + managing their own models while blocking no cross-tenant access. Any + other role, or an ADMIN naming a foreign tenant, is rejected. + """ + role = current_user.normalized_role + if role not in _MANAGE_ALLOWED_ROLES: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="This operation requires SU or tenant ADMIN role", + ) + if role != "SU" and target_tenant_id != current_user.tenant_id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Tenant admins may only manage models of their own tenant", + ) + + def _catalog_unavailable_response(status_code: HTTPStatus, **extra: Any) -> JSONResponse: """Uniform failure response shared by every catalog endpoint.""" content: Dict[str, Any] = { @@ -143,7 +185,10 @@ def _catalog_unavailable_response(status_code: HTTPStatus, **extra: Any) -> JSON return JSONResponse(status_code=status_code, content=content) -def _capacity_suggestion_response_to_model(result) -> ModelCapacitySuggestionResponse: +def _capacity_suggestion_response_to_model( + result, + reasoning_capability: Optional[dict] = None, +) -> ModelCapacitySuggestionResponse: suggestions = None if result.suggestions is not None: suggestions = CapacitySuggestionFields( @@ -156,6 +201,7 @@ def _capacity_suggestion_response_to_model(result) -> ModelCapacitySuggestionRes return ModelCapacitySuggestionResponse( suggestions=suggestions, + reasoning_capability=reasoning_capability, match_kind=result.match_kind.value, match_confidence=result.match_confidence.value if result.match_confidence else None, match_explanation=result.match_explanation, @@ -174,7 +220,12 @@ def _suggest_capacity_for_request(request: ModelCapacitySuggestionRequest) -> Mo model_type=request.model_type, enabled=CAPACITY_SUGGESTION_ENABLED, ) - return _capacity_suggestion_response_to_model(result) + reasoning_capability = get_model_reasoning_capability( + model_name=request.model_name, + base_url=request.base_url, + provider_hint=request.provider_hint, + ) + return _capacity_suggestion_response_to_model(result, reasoning_capability) def _capacity_suggestion_for_model_request(request: ModelRequest): @@ -199,7 +250,10 @@ def _capacity_suggestion_for_model_request(request: ModelRequest): @router.post("/create") -async def create_model(request: ModelRequest, authorization: Optional[str] = Header(None)): +async def create_model( + request: ModelRequest, + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), +): """Create a single model record for the current tenant. Responsibilities (App layer): @@ -210,15 +264,19 @@ async def create_model(request: ModelRequest, authorization: Optional[str] = Hea Args: request: Model configuration payload. - authorization: Bearer token header used to derive `user_id` and `tenant_id`. """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, tenant_id = current_user.user_id, current_user.tenant_id model_data = request.model_dump() accept_signal = pop_capacity_accept_signal(model_data) + # Batch-import flow control flag: popped here so it never reaches + # the service/DB layer (same contract as the accept-signal fields). + skip_backfill = bool(model_data.pop("skip_default_backfill", None)) logger.debug( f"Start to create model, user_id: {user_id}, tenant_id: {tenant_id}") - create_result = await create_model_for_tenant(user_id, tenant_id, model_data) + create_result = await create_model_for_tenant( + user_id, tenant_id, model_data, + skip_default_backfill=skip_backfill) if accept_signal is not None: _record_capacity_suggestion_accept( accept_signal["match_kind"], request.model_factory @@ -240,10 +298,43 @@ async def create_model(request: ModelRequest, authorization: Optional[str] = Hea status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) +@router.post("/backfill_defaults") +async def backfill_default_model_slots( + request: BackfillDefaultsRequest, + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), +): + """Finalize default-model auto-configuration after a batch import. + + The batch dialog creates its rows one HTTP call at a time with + skip_default_backfill set; this endpoint runs the auto-configuration + ONCE with the whole batch's models as candidates, so empty slots get + the best model of the batch instead of whichever row happened to be + created first. Occupied slots (user- or system-configured) are never + touched. + """ + try: + user_id, tenant_id = current_user.user_id, current_user.tenant_id + created_ids = _ids_for_created_models( + request.display_names, tenant_id) + auto_configured = _backfill_default_model_slots( + user_id, tenant_id, new_model_ids=created_ids) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "auto_configured_defaults": auto_configured, + "message": "Default model backfill completed" + }) + except TokenExpiredError as e: + logging.warning("Session expired") + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except Exception as e: + logging.error(f"Failed to backfill default model slots: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + @router.post("/suggest-capacity") async def suggest_model_capacity( request: ModelCapacitySuggestionRequest, - authorization: Optional[str] = Header(None), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """Return a non-mutating capacity suggestion for a model add/edit form. @@ -254,7 +345,6 @@ async def suggest_model_capacity( `result.data` unconditionally. """ try: - get_current_user_id(authorization) result = _suggest_capacity_for_request(request) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Successfully suggested model capacity", @@ -274,14 +364,16 @@ async def suggest_model_capacity( @router.get("/capacity-coverage") -async def get_model_capacity_coverage(authorization: Optional[str] = Header(None)): +async def get_model_capacity_coverage( + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): """Return bare-capacity LLM/VLM coverage for the current tenant. Wrapped in the shared `{message, data}` envelope; see `suggest_model_capacity` for the same rationale. """ try: - _, tenant_id = get_current_user_id(authorization) + tenant_id = current_user.tenant_id result = get_capacity_coverage(tenant_id) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Successfully retrieved model capacity coverage", @@ -298,7 +390,10 @@ async def get_model_capacity_coverage(authorization: Optional[str] = Header(None @router.post("/provider/create") -async def create_provider_model(request: ProviderModelRequest, authorization: Optional[str] = Header(None)): +async def create_provider_model( + request: ProviderModelRequest, + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), +): """Create or refresh provider models for the current tenant in memory only. This endpoint fetches models from the specified provider and merges existing @@ -307,11 +402,10 @@ async def create_provider_model(request: ProviderModelRequest, authorization: Op Args: request: Provider and model type information. - authorization: Bearer token header used to derive identity context. """ try: provider_model_config = request.model_dump() - _, tenant_id = get_current_user_id(authorization) + tenant_id = current_user.tenant_id model_list = await create_provider_models_for_tenant(tenant_id, provider_model_config) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Provider model created successfully", @@ -327,7 +421,10 @@ async def create_provider_model(request: ProviderModelRequest, authorization: Op @router.post("/provider/batch_create") -async def batch_create_models(request: BatchCreateModelsRequest, authorization: Optional[str] = Header(None)): +async def batch_create_models( + request: BatchCreateModelsRequest, + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), +): """Synchronize provider models for a tenant by creating/updating/deleting records. The request includes the authoritative list of models for a provider/type. @@ -336,11 +433,10 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: Args: request: Batch payload with provider, type, models, and optional API key. - authorization: Bearer token header used to derive identity context. """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, tenant_id = current_user.user_id, current_user.tenant_id batch_model_config = request.model_dump() # Strip W11 accept-signal fields off every model entry before the # batch reaches the service/DB layer. Same audit-only contract as @@ -361,6 +457,11 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: except TokenExpiredError as e: logging.warning("Session expired") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except ValueError as e: + # Malformed batch entries are client errors, not server faults. + logging.error(f"Failed to batch create models: {str(e)}") + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=str(e)) except Exception as e: logging.error(f"Failed to batch create models: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -368,16 +469,18 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: @router.post("/provider/list") -async def get_provider_list(request: ProviderModelRequest, authorization: Optional[str] = Header(None)): +async def get_provider_list( + request: ProviderModelRequest, + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): """List persisted models for a provider and type for the current tenant. Args: request: Provider and model type to filter. - authorization: Bearer token header used to derive identity context. """ try: - _, tenant_id = get_current_user_id(authorization) + tenant_id = current_user.tenant_id model_list = await list_provider_models_for_tenant( tenant_id, request.provider, request.model_type ) @@ -398,7 +501,7 @@ async def get_provider_list(request: ProviderModelRequest, authorization: Option async def update_single_model( request: dict, display_name: str = Query(..., description="Current display name of the model to update"), - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), ): """Update a single model by its current `display_name`. @@ -408,14 +511,13 @@ async def update_single_model( Args: request: Arbitrary model fields to update (may include new display_name). display_name: Current display name of the model (query parameter for lookup). - authorization: Bearer token header used to derive identity context. Raises: HTTPException: 404 if model not found, 409 if new `display_name` conflicts, 500 for unexpected errors. """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, tenant_id = current_user.user_id, current_user.tenant_id accept_signal = pop_capacity_accept_signal(request) await update_single_model_for_tenant(user_id, tenant_id, display_name, request) if accept_signal is not None: @@ -443,15 +545,17 @@ async def update_single_model( @router.post("/batch_update") -async def batch_update_models(request: List[dict], authorization: Optional[str] = Header(None)): +async def batch_update_models( + request: List[dict], + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), +): """Batch update multiple models for the current tenant. Args: request: List of partial model payloads with `model_id` fields. - authorization: Bearer token header used to derive identity context. """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, tenant_id = current_user.user_id, current_user.tenant_id await batch_update_models_for_tenant(user_id, tenant_id, request) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Batch update models successfully" @@ -466,7 +570,10 @@ async def batch_update_models(request: List[dict], authorization: Optional[str] @router.post("/delete") -async def delete_model(display_name: str = Query(..., embed=True), authorization: Optional[str] = Header(None)): +async def delete_model( + display_name: str = Query(..., embed=True), + current_user: CurrentUser = Depends(require(MODEL_DELETE_PERMISSION)), +): """Soft delete model(s) by `display_name` for the current tenant. Behavior: @@ -475,10 +582,9 @@ async def delete_model(display_name: str = Query(..., embed=True), authorization Args: display_name: Display name of the model to delete (unique key). - authorization: Bearer token header used to derive identity context. """ try: - user_id, tenant_id = get_current_user_id(authorization) + user_id, tenant_id = current_user.user_id, current_user.tenant_id logger.info( f"Start to delete model, user_id: {user_id}, tenant_id: {tenant_id}") model_name = await delete_model_for_tenant(user_id, tenant_id, display_name) @@ -500,7 +606,9 @@ async def delete_model(display_name: str = Query(..., embed=True), authorization @router.get("/list") -async def get_model_list(authorization: Optional[str] = Header(None)): +async def get_model_list( + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): """Get detailed information for all models for the current tenant. Returns each model enriched with repo-qualified `model_name` and a normalized @@ -508,9 +616,9 @@ async def get_model_list(authorization: Optional[str] = Header(None)): """ try: - user_id, tenant_id = get_current_user_id(authorization) + tenant_id = current_user.tenant_id logger.debug( - f"Start to list models, user_id: {user_id}, tenant_id: {tenant_id}") + f"Start to list models, user_id: {current_user.user_id}, tenant_id: {tenant_id}") model_list = await list_models_for_tenant(tenant_id) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Successfully retrieved model list", @@ -526,10 +634,12 @@ async def get_model_list(authorization: Optional[str] = Header(None)): @router.get("/llm_list") -async def get_llm_model_list(authorization: Optional[str] = Header(None)): +async def get_llm_model_list( + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): """Get list of LLM models for the current tenant.""" try: - _, tenant_id = get_current_user_id(authorization) + tenant_id = current_user.tenant_id llm_list = await list_llm_models_for_tenant(tenant_id) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Successfully retrieved LLM list", @@ -548,16 +658,15 @@ async def get_llm_model_list(authorization: Optional[str] = Header(None)): async def check_model_health( display_name: Annotated[str, Query(..., description="Display name to check")], model_type: Annotated[str, Query(..., description="...")], - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), ): """Check and update model connectivity, returning the latest status. Args: display_name: Display name of the model to check. - authorization: Bearer token header used to derive identity context. """ try: - _, tenant_id = get_current_user_id(authorization) + tenant_id = current_user.tenant_id result = await check_model_connectivity(display_name, tenant_id, model_type) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Successfully checked model connectivity", @@ -580,25 +689,44 @@ async def check_model_health( detail=str(e)) +def _normalize_probe_base_url(url: Optional[str]) -> str: + """Normalize a base_url for the probe key-fallback match. + + Only trailing slashes are stripped: the match must stay an exact-string + comparison — prefix or fuzzy matching would re-open the exfiltration + path the fallback guards against. + """ + return (url or "").rstrip("/") + + @router.post("/temporary_healthcheck") async def check_temporary_model_health( - request: ModelProbeRequest, authorization: Optional[str] = Header(None) + request: ModelProbeRequest, + current_user: CurrentUser = Depends(authenticate), ): """Verify connectivity for the provided model configuration without persisting it. + Authentication only: any tenant user may verify a candidate model config. + Args: request: Model configuration to verify. - authorization: Bearer token header used to enforce authentication. """ try: - _, tenant_id = get_current_user_id(authorization) # Edit-dialog probes arrive without the api_key (the backend never # returns the persisted key to the client, and the dialog leaves the # field empty to "keep existing"). Fall back to the stored key so - # verifying does not require retyping it. + # verifying does not require retyping it — but ONLY when the probe + # targets the stored endpoint itself: substituting the key while the + # caller controls base_url would let any tenant member exfiltrate a + # stored key by pointing the probe at their own server. if request.probe_model_id is not None and request.api_key in (None, "", "sk-no-api-key"): - stored_model = get_model_by_model_id(request.probe_model_id, tenant_id=tenant_id) - if stored_model and stored_model.get("api_key"): + stored_model = get_model_by_model_id(request.probe_model_id, tenant_id=current_user.tenant_id) + if ( + stored_model + and stored_model.get("api_key") + and _normalize_probe_base_url(request.base_url) + == _normalize_probe_base_url(stored_model.get("base_url")) + ): request.api_key = stored_model["api_key"] result = await verify_model_config_connectivity(request.model_dump()) if result.get("connectivity") is True: @@ -633,7 +761,7 @@ async def check_temporary_model_health( @router.post("/manage/healthcheck") async def manage_check_model_health( request: ManageTenantModelHealthcheckRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), ): """Check and update model connectivity for a specified tenant (admin/manage operation). @@ -641,15 +769,14 @@ async def manage_check_model_health( Args: request: Query request with target tenant_id and model display_name. - authorization: Bearer token header used to derive `user_id`. Returns: Connectivity check result with updated status. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) logger.debug( - f"Start to check model connectivity for tenant, user_id: {user_id}, " + f"Start to check model connectivity for tenant, user_id: {current_user.user_id}, " f"target_tenant_id: {request.tenant_id}, display_name: {request.display_name}") result = await check_model_connectivity( @@ -678,7 +805,7 @@ async def manage_check_model_health( @router.post("/manage/create") async def manage_create_model( request: ManageTenantModelCreateRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), ): """Create a model in a specified tenant (admin/manage operation). @@ -686,13 +813,13 @@ async def manage_create_model( Args: request: Model configuration with target tenant_id. - authorization: Bearer token header used to derive `user_id`. Returns: Success message on successful creation. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) + user_id = current_user.user_id logger.debug( f"Start to create model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}") @@ -729,7 +856,7 @@ async def manage_create_model( @router.post("/manage/update") async def manage_update_model( request: ManageTenantModelUpdateRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), ): """Update a model in a specified tenant (admin/manage operation). @@ -737,13 +864,13 @@ async def manage_update_model( Args: request: Update payload with target tenant_id and current display_name. - authorization: Bearer token header used to derive `user_id`. Returns: Success message on successful update. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) + user_id = current_user.user_id logger.debug( f"Start to update model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " f"current_display_name: {request.current_display_name}") @@ -781,7 +908,7 @@ async def manage_update_model( @router.post("/manage/delete") async def manage_delete_model( request: ManageTenantModelDeleteRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_DELETE_PERMISSION)), ): """Delete a model from a specified tenant (admin/manage operation). @@ -789,13 +916,13 @@ async def manage_delete_model( Args: request: Delete request with target tenant_id and display_name. - authorization: Bearer token header used to derive `user_id`. Returns: Success message with deleted model name. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) + user_id = current_user.user_id logger.debug( f"Start to delete model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " f"display_name: {request.display_name}") @@ -825,7 +952,7 @@ async def manage_delete_model( @router.post("/manage/batch_create") async def manage_batch_create_models( request: ManageBatchCreateModelsRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), ): """Batch create/update models in a specified tenant (admin/manage operation). @@ -834,13 +961,13 @@ async def manage_batch_create_models( Args: request: Batch payload with target tenant_id, provider, type, api_key, and models list. - authorization: Bearer token header used to derive `user_id`. Returns: Success message on completion. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) + user_id = current_user.user_id logger.debug( f"Start to batch create models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " f"provider: {request.provider}, type: {request.type}, models count: {len(request.models)}") @@ -878,7 +1005,7 @@ async def manage_batch_create_models( @router.post("/manage/list", response_model=ManageTenantModelListResponse) async def manage_list_models( request: ManageTenantModelListRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """List models for a specified tenant (admin/manage operation). @@ -886,15 +1013,14 @@ async def manage_list_models( Args: request: Query request with target tenant_id and pagination params. - authorization: Bearer token header used to derive `user_id`. Returns: Paginated model list for the specified tenant. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) logger.debug( - f"Start to list models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"Start to list models for tenant, user_id: {current_user.user_id}, target_tenant_id: {request.tenant_id}, " f"page: {request.page}, page_size: {request.page_size}") result = await list_models_for_admin( @@ -919,7 +1045,7 @@ async def manage_list_models( @router.post("/manage/provider/list") async def manage_list_provider_models( request: ManageProviderModelListRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """List provider models for a specified tenant (admin/manage operation). @@ -928,15 +1054,14 @@ async def manage_list_provider_models( Args: request: Query request with target tenant_id, provider, model_type. - authorization: Bearer token header used to derive `user_id`. Returns: List of available provider models for the specified tenant. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) logger.debug( - f"Start to list provider models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"Start to list provider models for tenant, user_id: {current_user.user_id}, target_tenant_id: {request.tenant_id}, " f"provider: {request.provider}, model_type: {request.model_type}") model_list = await list_provider_models_for_tenant( @@ -958,7 +1083,7 @@ async def manage_list_provider_models( @router.post("/manage/provider/create") async def manage_create_provider_models( request: ManageProviderModelCreateRequest, - authorization: Optional[str] = Header(None) + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), ): """Create/fetch provider models for a specified tenant (admin/manage operation). @@ -967,15 +1092,14 @@ async def manage_create_provider_models( Args: request: Query request with target tenant_id, provider, model_type, and optional api_key/base_url. - authorization: Bearer token header used to derive `user_id`. Returns: List of available provider models for the specified tenant. """ + _require_manage_scope(current_user, request.tenant_id) try: - user_id, _ = get_current_user_id(authorization) logger.debug( - f"Start to create provider models for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " + f"Start to create provider models for tenant, user_id: {current_user.user_id}, target_tenant_id: {request.tenant_id}, " f"provider: {request.provider}, model_type: {request.model_type}") # Build provider request dict for the service function diff --git a/backend/apps/northbound_app.py b/backend/apps/northbound_app.py index 7153914d1f..01b9b0d9aa 100644 --- a/backend/apps/northbound_app.py +++ b/backend/apps/northbound_app.py @@ -1,6 +1,6 @@ import logging from http import HTTPStatus -from typing import Optional, Dict, Any +from typing import Any, Dict, List, Optional from urllib.parse import urlparse, unquote import re import uuid @@ -18,10 +18,12 @@ RuntimeServiceTimeoutError, RuntimeServiceUnavailableError, RuntimeUpstreamError, + TenantResourceLimitError, UnauthorizedError, NotFoundException, UnauthorizedError, ValidationError, + tenant_resource_limit_error_payload, ) from consts.model import ( ApiKeyTargetRequest, @@ -36,6 +38,7 @@ refresh_user_api_key, revoke_user_api_keys, ) +from services.audit_service import record_security_event from services.northbound_service import ( NorthboundContext, get_conversation_history, @@ -195,6 +198,40 @@ def _raise_api_key_http_exception(exc: Exception) -> None: raise exc +def _audit_safe_target(result: Dict[str, Any], request_id: str) -> Dict[str, Any]: + """Pick non-secret target fields for the audit trail. + + The service results carry the freshly created plaintext API key; only the + whitelisted identifiers below are handed to the audit entry. + """ + return { + "target_user_id": (result or {}).get("user_id"), + "target_email": (result or {}).get("email"), + "revoked_count": (result or {}).get("revoked_count"), + "request_id": request_id, + } + + +def _audit_safe_batch( + payload: ApiUserBatchCreateRequest, + created: List[Dict[str, Any]], + request_id: str, +) -> Dict[str, Any]: + """Pick non-secret batch fields for the audit trail. + + Each created item carries a plaintext API key, so only the request intent + and the created user ids (bounded) are recorded. + """ + created = created or [] + return { + "role": payload.role, + "group_id": payload.group_id, + "count": len(created), + "user_ids": [item.get("user_id") for item in created][:20], + "request_id": request_id, + } + + @router.post( "/api-users/batch", status_code=HTTPStatus.CREATED, @@ -214,11 +251,19 @@ async def create_api_users_batch_endpoint( group_id=payload.group_id, count=payload.count, ) + record_security_event("northbound_api_users_batch_create", request=request, + user_id=ctx.user_id, tenant_id=ctx.tenant_id, + details=_audit_safe_batch(payload, data, ctx.request_id)) return JSONResponse( status_code=HTTPStatus.CREATED, content={"message": "success", "requestId": ctx.request_id, "data": data}, ) except Exception as exc: + if isinstance(exc, TenantResourceLimitError): + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(exc), + ) _raise_api_key_http_exception(exc) @@ -235,6 +280,9 @@ async def refresh_api_key_endpoint( user_id=payload.user_id, email=str(payload.email) if payload.email else None, ) + record_security_event("northbound_api_key_refresh", request=request, + user_id=ctx.user_id, tenant_id=ctx.tenant_id, + details=_audit_safe_target(data, ctx.request_id)) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "success", "requestId": ctx.request_id, "data": data}, @@ -259,6 +307,9 @@ async def revoke_api_key_endpoint( user_id=target.user_id, email=str(target.email) if target.email else None, ) + record_security_event("northbound_api_key_revoke", request=request, + user_id=ctx.user_id, tenant_id=ctx.tenant_id, + details=_audit_safe_target(data, ctx.request_id)) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "success", "requestId": ctx.request_id, "data": data}, diff --git a/backend/apps/northbound_knowledge_app.py b/backend/apps/northbound_knowledge_app.py index c3b4fa6fcc..1ff2673289 100644 --- a/backend/apps/northbound_knowledge_app.py +++ b/backend/apps/northbound_knowledge_app.py @@ -139,6 +139,9 @@ async def create_new_index( embedding_model_id=embedding_model_id, preserve_source_file=preserve_source_file, ) + except AppException: + # Preserve the standard error code/details for knowledge resource limits. + raise except LimitExceededError as e: logger.exception("Rate limit exceeded while creating index") raise HTTPException( @@ -527,6 +530,8 @@ async def upload_files( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise + except AppException: + raise except Exception: logger.exception("File upload error") raise HTTPException( diff --git a/backend/apps/oauth_app.py b/backend/apps/oauth_app.py index ef760c6f38..942aea4888 100644 --- a/backend/apps/oauth_app.py +++ b/backend/apps/oauth_app.py @@ -9,7 +9,13 @@ from consts.const import JWT_EXPIRY_SECONDS from consts.model import OAuthCompleteRequest -from consts.exceptions import OAuthLinkError, OAuthProviderError, TenantResourceLimitError, UnauthorizedError +from consts.exceptions import ( + OAuthLinkError, + OAuthProviderError, + TenantResourceLimitError, + UnauthorizedError, + tenant_resource_limit_error_payload, +) from consts.oauth_providers import get_all_provider_definitions from database.oauth_account_db import get_oauth_account_by_provider from services.oauth_service import ( @@ -34,6 +40,7 @@ get_current_user_id, get_supabase_admin_client, ) +from services.audit_service import record_security_event logger = logging.getLogger(__name__) router = APIRouter(prefix="/user/oauth", tags=["oauth"]) @@ -94,6 +101,7 @@ async def link(provider: str, authorization: Optional[str] = Header(None)): @router.get("/callback") async def callback( + http_request: Request, provider: str, code: str = "", state: str = "", @@ -209,6 +217,9 @@ async def callback( expiry_seconds = JWT_EXPIRY_SECONDS expires_at = calculate_expires_at(jwt_token) + record_security_event("oauth_login", request=http_request, + user_id=supabase_user_id, user_email=email, + details={"provider": provider, "linked": bool(link_user_id)}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -231,14 +242,8 @@ async def callback( except TenantResourceLimitError as e: logger.warning(f"OAuth callback rejected by tenant resource limit for provider={provider}: {e}") return JSONResponse( - status_code=HTTPStatus.BAD_REQUEST, - content={ - "message": str(e), - "data": { - "oauth_error": "tenant_resource_limit_exceeded", - "oauth_error_description": str(e), - }, - }, + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(e), ) except OAuthLinkError as e: logger.warning(f"OAuth callback link failed for provider={provider}: {e}") @@ -301,6 +306,10 @@ async def complete( password=request_data.password, invite_code=request_data.invite_code, ) + completed_user = (result or {}).get("user") or {} + record_security_event("oauth_signup", request=request, + user_id=completed_user.get("id"), + user_email=completed_user.get("email")) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "OAuth account completed", "data": result}, @@ -313,7 +322,10 @@ async def complete( ) raise HTTPException(status_code=status_code, detail=str(e)) except TenantResourceLimitError as e: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(e), + ) except PydanticValidationError as e: raise HTTPException( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, @@ -352,13 +364,16 @@ async def get_accounts(authorization: Optional[str] = Header(None)): @router.delete("/accounts/{provider}") -async def delete_account(provider: str, authorization: Optional[str] = Header(None)): +async def delete_account(provider: str, http_request: Request, authorization: Optional[str] = Header(None)): if not authorization: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in") try: - user_id, _ = get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) unlink_account(user_id, provider) + record_security_event("oauth_unlink", request=http_request, + user_id=user_id, tenant_id=tenant_id, + details={"provider": provider}) return JSONResponse( status_code=HTTPStatus.OK, content={ diff --git a/backend/apps/remote_mcp_app.py b/backend/apps/remote_mcp_app.py index 93a8690331..69ef72faf3 100644 --- a/backend/apps/remote_mcp_app.py +++ b/backend/apps/remote_mcp_app.py @@ -8,7 +8,9 @@ from http import HTTPStatus from consts.const import ENABLE_UPLOAD_IMAGE +from consts.error_code import ErrorCode from consts.exceptions import ( + AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, @@ -17,6 +19,7 @@ McpNameConflictError, McpPortConflictError, UnauthorizedError, + TenantResourceLimitError, ) from consts.model import ( MCPConfigRequest, @@ -59,6 +62,15 @@ _MCP_SERVICE_ID_DESC = Query(..., description="MCP service ID") +def _as_mcp_resource_limit_exception(error: TenantResourceLimitError) -> AppException: + """Convert the legacy quota exception to the standard API error envelope.""" + return AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + str(error), + details=getattr(error, "details", None), + ) + + # --------------------------------------------------------------------------- # Tools Endpoint # --------------------------------------------------------------------------- @@ -196,6 +208,9 @@ async def add_mcp_service_endpoint( status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e) or "MCP connection failed" ) + except TenantResourceLimitError as e: + logger.warning("MCP service creation rejected by tenant resource limit: %s", e) + raise _as_mcp_resource_limit_exception(e) from e except McpValidationError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: @@ -268,6 +283,9 @@ async def add_container_mcp_service_endpoint( status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e) or "MCP connection failed" ) + except TenantResourceLimitError as e: + logger.warning("Container MCP creation rejected by tenant resource limit: %s", e) + raise _as_mcp_resource_limit_exception(e) from e except Exception as e: logger.error(f"Failed to add container MCP service: {e}") raise HTTPException( @@ -326,6 +344,15 @@ async def on_container_started(container_info: dict) -> None: result = await deployment_task yield f"data: {json.dumps({'status': 'success', 'data': result}, ensure_ascii=False)}\n\n" + except TenantResourceLimitError as exc: + logger.warning("Streaming MCP creation rejected by tenant resource limit: %s", exc) + error_event = { + "status": "error", + "code": ErrorCode.TENANT_RESOURCE_EXCEEDED.value, + "message": str(exc), + "details": getattr(exc, "details", None), + } + yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" except Exception: # Keep internal exception details out of the externally visible SSE # payload; the server log retains the traceback for diagnostics. diff --git a/backend/apps/skill_app.py b/backend/apps/skill_app.py index 6d49ae88ab..3794070cb0 100644 --- a/backend/apps/skill_app.py +++ b/backend/apps/skill_app.py @@ -4,17 +4,20 @@ from http import HTTPStatus from typing import Any, Dict, List, Optional -from fastapi import APIRouter, File, Form, Header, HTTPException, Query, UploadFile +from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile from pydantic import BaseModel, Field from starlette.responses import JSONResponse, StreamingResponse -from consts.exceptions import ForbiddenError, SkillException, UnauthorizedError +from consts.exceptions import AppException, ForbiddenError, SkillException, UnauthorizedError +from consts.const import ENABLE_AGENT_WORKBENCH from consts.model import ( NL2SkillRunRequest, SkillCreateRequest, SkillInstanceInfoRequest, SkillUpdateRequest, ) +from permissions.depends import require +from permissions.models import CurrentUser from services.asset_owner_visibility import can_view_skill from services.agent_draft_permission_service import ( AgentDraftEditError, @@ -22,6 +25,10 @@ require_agent_draft_edit, ) from services.nl2skill_service import create_nl2skill_stream +from services.workbench_creation_history_service import ( + prepare_creation_history, + persist_creation_stream, +) from management.services.skill.service import ( SkillService, UnsupportedSkillFilePreview, @@ -38,6 +45,7 @@ router = APIRouter(prefix="/skills", tags=["skills"]) skill_creator_router = APIRouter(prefix="/skills", tags=["nl2skill"]) +require_skill_create_permission = require("skill:create") def _asset_owner_skill_view_denied_response(skill: Optional[Dict[str, Any]], tenant_id: str): @@ -149,6 +157,8 @@ async def install_skills( "installed": installed_names, "total": len(installed_names) }) + except AppException: + raise except Exception as e: logger.error(f"Error installing skills: {e}") raise HTTPException(status_code=500, detail="Internal server error") @@ -189,6 +199,8 @@ async def create_skill( return JSONResponse(content=skill, status_code=201) except UnauthorizedError as e: raise HTTPException(status_code=401, detail=str(e)) + except AppException: + raise except SkillException as e: error_msg = str(e).lower() if "already exists" in error_msg: @@ -238,6 +250,8 @@ async def create_skill_from_file( except UnauthorizedError as e: logger.warning(f"Unauthorized: {e}") raise HTTPException(status_code=401, detail=str(e)) + except AppException: + raise except ForbiddenError as e: raise HTTPException(status_code=403, detail=str(e)) except SkillException as e: @@ -373,6 +387,8 @@ async def update_skill_from_file( return JSONResponse(content=skill) except UnauthorizedError as e: raise HTTPException(status_code=401, detail=str(e)) + except AppException: + raise except ForbiddenError as e: raise HTTPException(status_code=403, detail=str(e)) except SkillException as e: @@ -741,9 +757,12 @@ async def delete_skill( @skill_creator_router.post("/nl2skill/run") async def nl2skill_run_api( request: NL2SkillRunRequest, - authorization: Optional[str] = Header(None) + authorization: Optional[str] = Header(None), + _current_user: CurrentUser = Depends(require_skill_create_permission), ): - """Run one non-persistent, multi-turn NL2Skill conversation turn.""" + """Run NL2Skill; Workbench turns opt into conversation persistence.""" + if (request.persist_history or request.workbench_config is not None) and not ENABLE_AGENT_WORKBENCH: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail={"code": "WORKBENCH_DISABLED"}) try: _, tenant_id, user_language = get_current_user_info(authorization) except Exception as e: @@ -756,7 +775,35 @@ async def nl2skill_run_api( tenant_id=tenant_id, language=request.language or user_language or "zh", ) + if request.persist_history: + user_id, _ = get_current_user_id(authorization) + conversation_id, assistant_index = prepare_creation_history( + conversation_id=request.conversation_id, + mode="skill_create", + query=request.query, + minio_files=request.minio_files, + workbench_config=request.workbench_config, + agent_id=None, + user_id=user_id, + tenant_id=tenant_id, + retry_user_message_id=request.retry_user_message_id, + retry_message_index=request.retry_message_index, + ) + stream = persist_creation_stream( + stream, + conversation_id=conversation_id, + assistant_index=assistant_index, + user_id=user_id, + tenant_id=tenant_id, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={"conversation_id": str(conversation_id)}, + ) return StreamingResponse(stream, media_type="text/event-stream") + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid Workbench creation session.") from exc except HTTPException: raise except Exception: diff --git a/backend/apps/tag_management_app.py b/backend/apps/tag_management_app.py index 163c33ba55..8b85171a63 100644 --- a/backend/apps/tag_management_app.py +++ b/backend/apps/tag_management_app.py @@ -52,6 +52,11 @@ def _require_manage_context(authorization: str | None) -> tuple[str, str, str]: return user_id, tenant_id, role +def _require_read_context(authorization: str | None) -> tuple[str, str, str]: + """Return the authenticated tenant context for read-only tag metadata.""" + return get_current_user_context(authorization) + + def _assignment_caller(authorization: str | None) -> AuthenticatedCaller: user_id, tenant_id, role = get_current_user_context(authorization) return AuthenticatedCaller( @@ -285,7 +290,7 @@ async def replace_resource_tag_assignments( @router.get("", response_model=list[TagLibraryResponse]) def list_tag_libraries(authorization: str | None = Header(None)): - _, tenant_id, _ = _require_manage_context(authorization) + _, tenant_id, _ = _require_read_context(authorization) try: return TagManagementService.list_libraries(tenant_id) except TagManagementNotFoundError as error: @@ -301,7 +306,7 @@ def list_tag_libraries(authorization: str | None = Header(None)): @router.get("/{bucket_id}/definitions", response_model=list[TagDefinitionResponse]) def list_tag_definitions(bucket_id: int, authorization: str | None = Header(None)): - _, tenant_id, _ = _require_manage_context(authorization) + _, tenant_id, _ = _require_read_context(authorization) try: return TagManagementService.list_definitions(tenant_id, bucket_id) except TagManagementNotFoundError as error: diff --git a/backend/apps/tenant_app.py b/backend/apps/tenant_app.py index 9ce1e6aa50..50ac964642 100644 --- a/backend/apps/tenant_app.py +++ b/backend/apps/tenant_app.py @@ -4,7 +4,7 @@ import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Header, Body +from fastapi import APIRouter, Body, Header, HTTPException, Request from http import HTTPStatus from starlette.responses import JSONResponse @@ -13,7 +13,15 @@ TenantCreateRequest, TenantUpdateRequest, ) -from consts.exceptions import ForbiddenError, NotFoundException, ValidationError, UnauthorizedError +from consts.exceptions import ( + ForbiddenError, + NotFoundException, + TenantResourceLimitError, + UnauthorizedError, + ValidationError, + tenant_resource_limit_error_payload, +) +from services.audit_service import record_security_event from services.tenant_service import ( create_tenant, get_tenant_info_for_user, @@ -30,6 +38,7 @@ @router.post("", response_model=None) async def create_tenant_endpoint( request: TenantCreateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -44,7 +53,7 @@ async def create_tenant_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Create tenant tenant_info = create_tenant( @@ -57,6 +66,10 @@ async def create_tenant_endpoint( logger.info(f"Created tenant {tenant_info['tenant_id']} by user {user_id}") + record_security_event("tenant_create", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"tenant_id": (tenant_info or {}).get("tenant_id"), + "tenant_name": request.tenant_name}) return JSONResponse( status_code=HTTPStatus.CREATED, content={ @@ -71,6 +84,12 @@ async def create_tenant_endpoint( status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc) ) + except TenantResourceLimitError as exc: + logger.warning("Tenant creation rejected by resource limit: %s", exc) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(exc), + ) except ValidationError as exc: logger.warning(f"Tenant creation validation error: {str(exc)}") raise HTTPException( @@ -183,6 +202,7 @@ async def get_all_tenants_endpoint( async def update_tenant_endpoint( tenant_id: str, request: TenantUpdateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -198,7 +218,7 @@ async def update_tenant_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Update tenant updated_tenant = update_tenant_info( @@ -209,6 +229,10 @@ async def update_tenant_endpoint( logger.info(f"Updated tenant {tenant_id} by user {user_id}") + record_security_event("tenant_update", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"tenant_id": tenant_id, + "tenant_name": request.tenant_name}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -246,6 +270,7 @@ async def update_tenant_endpoint( @router.delete("/{tenant_id}") async def delete_tenant_endpoint( tenant_id: str, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -270,13 +295,16 @@ async def delete_tenant_endpoint( """ try: # Get current user ID from token - user_id, _ = get_current_user_id(authorization) + user_id, operator_tenant_id = get_current_user_id(authorization) # Perform tenant deletion with all associated resources await delete_tenant(tenant_id, deleted_by=user_id) logger.info(f"Deleted tenant {tenant_id} and all associated resources by user {user_id}") + record_security_event("tenant_delete", request=http_request, + user_id=user_id, tenant_id=operator_tenant_id, + details={"tenant_id": tenant_id}) return JSONResponse( status_code=HTTPStatus.OK, content={ diff --git a/backend/apps/tenant_config_app.py b/backend/apps/tenant_config_app.py index dfddd97f4d..a51095dae3 100644 --- a/backend/apps/tenant_config_app.py +++ b/backend/apps/tenant_config_app.py @@ -4,7 +4,13 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse -from consts.const import DEPLOYMENT_VERSION, APP_VERSION, ENABLE_AIDP_KNOWLEDGE +from consts.const import ( + APP_VERSION, + DEPLOYMENT_VERSION, + ENABLE_AIDP_KNOWLEDGE, + ENABLE_AGENT_WORKBENCH, + HIDE_HOME_PAGE, +) logger = logging.getLogger("tenant_config_app") router = APIRouter(prefix="/tenant_config") @@ -21,6 +27,8 @@ def get_deployment_version(): content={"deployment_version": DEPLOYMENT_VERSION, "app_version": APP_VERSION, "enable_aidp_knowledge": ENABLE_AIDP_KNOWLEDGE, + "enable_agent_workbench": ENABLE_AGENT_WORKBENCH, + "hide_home_page": HIDE_HOME_PAGE, "status": "success"} ) except Exception as e: diff --git a/backend/apps/tool_config_app.py b/backend/apps/tool_config_app.py index d0219771a5..7b2b427743 100644 --- a/backend/apps/tool_config_app.py +++ b/backend/apps/tool_config_app.py @@ -244,6 +244,10 @@ async def import_openapi_service_api( mcp_result = _refresh_openapi_services_in_mcp(tenant_id) result["mcp_refresh"] = mcp_result + # The agent configuration page reads the persisted tool list. Refreshing + # the MCP runtime alone does not make newly imported tools selectable. + await update_tool_list(tenant_id=tenant_id, user_id=user_id) + return JSONResponse( status_code=HTTPStatus.OK, content={ diff --git a/backend/apps/user_app.py b/backend/apps/user_app.py index 3a6239dd08..56c37d2d58 100644 --- a/backend/apps/user_app.py +++ b/backend/apps/user_app.py @@ -4,14 +4,21 @@ import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Header +from fastapi import APIRouter, Header, HTTPException, Request from http import HTTPStatus from starlette.responses import JSONResponse from consts.model import ( UserListRequest, UserUpdateRequest ) -from consts.exceptions import ForbiddenError, NotFoundException, UnauthorizedError +from consts.exceptions import ( + ForbiddenError, + NotFoundException, + TenantResourceLimitError, + UnauthorizedError, + tenant_resource_limit_error_payload, +) +from services.audit_service import record_security_event from services.user_service import ( delete_user_and_cleanup, get_users_for_requester, update_user_for_requester ) @@ -93,6 +100,7 @@ async def get_users_endpoint( async def update_user_endpoint( user_id: str, request: UserUpdateRequest, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -121,6 +129,11 @@ async def update_user_endpoint( logger.info(f"Updated user {user_id} by user {current_user_id}") + record_security_event("user_update", request=http_request, + user_id=current_user_id, tenant_id=requester_tenant_id, + details={"target_user_id": user_id, + "changes": {key: value for key, value in request.model_dump().items() + if value is not None}}) return JSONResponse( status_code=HTTPStatus.OK, content={ @@ -135,6 +148,12 @@ async def update_user_endpoint( raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) except NotFoundException as exc: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except TenantResourceLimitError as exc: + logger.warning("User update rejected by resource limit: %s", exc) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(exc), + ) except ValueError as exc: logger.warning(f"User update validation error for user {user_id}: {str(exc)}") raise HTTPException( @@ -153,6 +172,7 @@ async def update_user_endpoint( @router.delete("/{user_id}") async def delete_user_endpoint( user_id: str, + http_request: Request, authorization: Optional[str] = Header(None) ) -> JSONResponse: """ @@ -173,7 +193,7 @@ async def delete_user_endpoint( """ try: # Get current user ID from token for access control - current_user_id, _ = get_current_user_id(authorization) + current_user_id, operator_tenant_id = get_current_user_id(authorization) # Get user tenant ID for cleanup operations user_tenant = get_user_tenant_by_user_id(user_id) @@ -187,6 +207,10 @@ async def delete_user_endpoint( logger.info(f"Permanently deleted user {user_id} by admin {current_user_id}") + record_security_event("user_delete", request=http_request, + user_id=current_user_id, tenant_id=operator_tenant_id, + details={"target_user_id": user_id, + "target_tenant_id": tenant_id}) return JSONResponse( status_code=HTTPStatus.OK, content={ diff --git a/backend/apps/user_management_app.py b/backend/apps/user_management_app.py index 53087193f7..61259ea3a9 100644 --- a/backend/apps/user_management_app.py +++ b/backend/apps/user_management_app.py @@ -16,7 +16,9 @@ UserRegistrationException, AppException, UnauthorizedError, + TenantResourceLimitError, ValidationError, + tenant_resource_limit_error_payload, ) from consts.error_code import ErrorCode from services.cas_service import build_logout_url, CasAuthenticationError @@ -25,6 +27,7 @@ get_session_by_authorization, get_user_info, create_token, list_tokens_by_user, delete_token, \ update_password, get_provider_username from services.user_service import delete_user_and_cleanup +from services.audit_service import record_security_event from utils.auth_utils import ( extract_session_id_from_authorization, get_current_user_context, @@ -57,7 +60,7 @@ async def service_health(): @router.post("/signup") -async def signup(request: UserSignUpRequest): +async def signup(request: UserSignUpRequest, http_request: Request): """User registration""" try: user_data = await signup_user_with_invitation(email=request.email, @@ -65,6 +68,11 @@ async def signup(request: UserSignUpRequest): invite_code=request.invite_code, auto_login=request.auto_login) success_message = "🎉 User account registered successfully! Please start experiencing the AI assistant service." + signup_user_info = (user_data or {}).get("user") or {} + record_security_event("user_signup", request=http_request, + user_id=signup_user_info.get("id"), + user_email=signup_user_info.get("email") or request.email, + details={"registration_type": (user_data or {}).get("registration_type", "")}) return JSONResponse(status_code=HTTPStatus.OK, content={"message": success_message, "data": user_data}) except NoInviteCodeException as e: @@ -75,6 +83,12 @@ async def signup(request: UserSignUpRequest): logging.error(f"User registration failed by invite code: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="INVITE_CODE_INVALID") + except TenantResourceLimitError as e: + logging.warning("User registration rejected by resource limit: %s", e) + return JSONResponse( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=tenant_resource_limit_error_payload(e), + ) except ValidationError as e: detail = str(e) if detail == ASSET_OWNER_SIGNUP_USE_OAUTH_DETAIL: @@ -104,11 +118,16 @@ async def signup(request: UserSignUpRequest): @router.post("/signin") -async def signin(request: UserSignInRequest): +async def signin(request: UserSignInRequest, http_request: Request): """User login""" try: signin_content = await signin_user(email=request.email, password=request.password) + signin_data = (signin_content or {}).get("data") or {} + signin_user_info = signin_data.get("user") or {} + record_security_event("user_signin", request=http_request, + user_id=signin_user_info.get("id"), + user_email=signin_user_info.get("email") or request.email) return JSONResponse(status_code=HTTPStatus.OK, content=signin_content) except AuthApiError as e: @@ -157,6 +176,13 @@ async def logout(request: Request): # Make logout idempotent: if no token or token expired, still return success session_id = None cas_logout_url = "" + # Audit-only identity resolution: failure never affects the logout flow. + logout_user_id, logout_tenant_id = None, None + if authorization: + try: + logout_user_id, logout_tenant_id = get_current_user_id(authorization) + except Exception: + pass if authorization: session_id = extract_session_id_from_authorization(authorization) if session_id: @@ -174,6 +200,9 @@ async def logout(request: Request): # Ignore sign out errors to keep logout idempotent logging.warning( f"Sign out encountered an error but will be ignored: {str(signout_err)}") + record_security_event("user_logout", request=request, + user_id=logout_user_id, tenant_id=logout_tenant_id, + session_id=session_id) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Logout successful", @@ -317,6 +346,8 @@ async def revoke_user_account(request: Request): # Orchestrate revoke for regular user await delete_user_and_cleanup(user_id=user_id, tenant_id=tenant_id) + record_security_event("account_revoke", request=request, + user_id=user_id, tenant_id=tenant_id) return JSONResponse(status_code=HTTPStatus.OK, content={"message": "User account revoked"}) except UnauthorizedError as e: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) @@ -330,6 +361,7 @@ async def revoke_user_account(request: Request): @router.post("/tokens") async def create_token_endpoint( + http_request: Request, authorization: Optional[str] = Header(None) ): """Create a new token for the authenticated user. @@ -342,12 +374,15 @@ async def create_token_endpoint( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: No authorization header found") - user_id, _ = get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: missing user_id in JWT token") result = create_token(str(user_id)) + record_security_event("token_create", request=http_request, + user_id=user_id, tenant_id=tenant_id, + details={"token_id": (result or {}).get("token_id")}) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "success", "data": result} @@ -400,6 +435,7 @@ async def list_tokens_endpoint( @router.delete("/tokens/{token_id}") async def delete_token_endpoint( token_id: int, + http_request: Request, authorization: Optional[str] = Header(None) ): """Soft delete a token. @@ -411,7 +447,7 @@ async def delete_token_endpoint( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: No authorization header found") - user_id, _ = get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: missing user_id in JWT token") @@ -421,6 +457,9 @@ async def delete_token_endpoint( raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Token not found or not owned by user") + record_security_event("token_delete", request=http_request, + user_id=user_id, tenant_id=tenant_id, + details={"token_id": token_id}) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "success", "data": {"token_id": token_id}} @@ -436,6 +475,7 @@ async def delete_token_endpoint( @router.put("/password") async def update_password_endpoint( request: UpdatePasswordRequest, + http_request: Request, authorization: Optional[str] = Header(None) ): """Update current user's password. @@ -448,7 +488,7 @@ async def update_password_endpoint( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: No authorization token provided") - user_id, _ = get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) if not user_id: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: missing user_id in JWT token") @@ -461,6 +501,8 @@ async def update_password_endpoint( logger.info(f"Password updated successfully for user {user_id}") + record_security_event("password_update", request=http_request, + user_id=user_id, tenant_id=tenant_id) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "Password updated successfully"} diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index cdf29b5995..5c4a33f32a 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -130,6 +130,9 @@ def create_new_index( ) except HTTPException: raise + except AppException: + # Keep structured knowledge-resource errors (including HTTP 429) intact. + raise except DuplicateError as e: raise HTTPException( status_code=HTTPStatus.CONFLICT, diff --git a/backend/configs/model_catalog.json b/backend/configs/model_catalog.json index 3740d86a9a..6b424c138a 100644 --- a/backend/configs/model_catalog.json +++ b/backend/configs/model_catalog.json @@ -1,7 +1,7 @@ { "version": "1.0.0", "metadata": { - "updated_at": "2026-08-05", + "updated_at": "2026-09-18", "description": "Nexent 预置模型目录 - 自动填充模型配置,减少用户手动输入" }, "providers": { @@ -171,6 +171,7 @@ "dashscope": { "display_name": "阿里灵积 DashScope", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/", + "model_factory": "dashscope", "models": { "qwen-plus": { "model_type": "llm", @@ -212,6 +213,32 @@ "tokenizer_family": "qwen", "capability_profile_version": "qwen/qwen-long@1" }, + "qwen3.8-max": { + "model_type": "llm", + "display_name": "千问3.8-Max", + "tokenizer_family": "qwen", + "capability_profile_version": "qwen/qwen3.8-max@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "xhigh"], + "default": "xhigh", + "source": "catalog" + } + }, + "qwen3.8-flash": { + "model_type": "llm", + "display_name": "千问3.8-Flash", + "tokenizer_family": "qwen", + "capability_profile_version": "qwen/qwen3.8-flash@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "xhigh"], + "default": "medium", + "source": "catalog" + } + }, "qwen-vl-max": { "model_type": "vlm", "display_name": "千问VL-Max", @@ -332,6 +359,23 @@ "tokenizer_family": "o200k_base", "capability_profile_version": "openai/gpt-4.1-mini@1" }, + "o3-mini": { + "model_type": "llm", + "display_name": "o3-mini", + "context_window_tokens": 200000, + "max_input_tokens": 185000, + "max_output_tokens": 100000, + "default_output_reserve_tokens": 16384, + "tokenizer_family": "o200k_base", + "capability_profile_version": "openai/o3-mini@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high"], + "default": "medium", + "source": "catalog" + } + }, "o3": { "model_type": "llm", "display_name": "o3", @@ -340,7 +384,82 @@ "max_output_tokens": 100000, "default_output_reserve_tokens": 16384, "tokenizer_family": "o200k_base", - "capability_profile_version": "openai/o3@1" + "capability_profile_version": "openai/o3@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high"], + "default": "medium", + "source": "catalog" + } + }, + "o4-mini": { + "model_type": "llm", + "display_name": "o4-mini", + "context_window_tokens": 200000, + "max_input_tokens": 185000, + "max_output_tokens": 100000, + "default_output_reserve_tokens": 16384, + "tokenizer_family": "o200k_base", + "capability_profile_version": "openai/o4-mini@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high"], + "default": "medium", + "source": "catalog" + } + }, + "gpt-5": { + "model_type": "llm", + "display_name": "GPT-5", + "context_window_tokens": 400000, + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "default_output_reserve_tokens": 16384, + "tokenizer_family": "o200k_base", + "capability_profile_version": "openai/gpt-5@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["minimal", "low", "medium", "high"], + "default": "medium", + "source": "catalog" + } + }, + "gpt-5-mini": { + "model_type": "llm", + "display_name": "GPT-5 Mini", + "context_window_tokens": 400000, + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "default_output_reserve_tokens": 16384, + "tokenizer_family": "o200k_base", + "capability_profile_version": "openai/gpt-5-mini@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["minimal", "low", "medium", "high"], + "default": "medium", + "source": "catalog" + } + }, + "gpt-5.1": { + "model_type": "llm", + "display_name": "GPT-5.1", + "context_window_tokens": 400000, + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "default_output_reserve_tokens": 16384, + "tokenizer_family": "o200k_base", + "capability_profile_version": "openai/gpt-5.1@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["none", "low", "medium", "high"], + "default": "none", + "source": "catalog" + } }, "text-embedding-3-large": { "model_type": "embedding", @@ -365,7 +484,22 @@ "volcengine": { "display_name": "火山引擎", "base_url": "https://ark.cn-beijing.volces.com/api/v3/", + "model_factory": "volcengine", "models": { + "doubao-seed-2-1-pro-260628": { + "model_type": "llm", + "display_name": "豆包 Seed 2.1 Pro", + "tokenizer_family": "doubao", + "capability_profile_version": "volcengine/doubao-seed-2-1-pro@1", + "reasoning_capability": { + "status": "supported", + "control": "toggle", + "levels": ["none", "high"], + "default": "high", + "wire_format": "thinking_toggle", + "source": "catalog" + } + }, "speech_istreaming": { "model_type": "stt", "display_name": "火山流式语音识别", @@ -400,6 +534,7 @@ "deepseek": { "display_name": "DeepSeek 官方平台", "base_url": "https://api.deepseek.com/v1/", + "model_factory": "deepseek", "models": { "deepseek-v4-pro": { "model_type": "llm", @@ -411,7 +546,14 @@ "tokenizer_family": "deepseek", "capability_profile_version": "deepseek/v4-pro@1", "timeout_seconds": 300, - "concurrency_limit": 10 + "concurrency_limit": 10, + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "high", "max"], + "default": "high", + "source": "catalog" + } }, "deepseek-v4-flash": { "model_type": "llm", @@ -423,7 +565,241 @@ "tokenizer_family": "deepseek", "capability_profile_version": "deepseek/v4-flash@1", "timeout_seconds": 240, - "concurrency_limit": 16 + "concurrency_limit": 16, + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "high", "max"], + "default": "high", + "source": "catalog" + } + } + } + }, + "zhipu": { + "display_name": "智谱 Z.AI", + "base_url": "https://open.bigmodel.cn/api/paas/v4/", + "model_factory": "zhipu", + "models": { + "glm-5.3": { + "model_type": "llm", + "display_name": "GLM-5.3", + "tokenizer_family": "glm", + "capability_profile_version": "zhipu/glm-5.3@1", + "reasoning_capability": { + "status": "supported", + "control": "toggle", + "levels": ["high"], + "default": "high", + "wire_format": "thinking_toggle", + "source": "catalog" + } + }, + "glm-5.2": { + "model_type": "llm", + "display_name": "GLM-5.2", + "tokenizer_family": "glm", + "capability_profile_version": "zhipu/glm-5.2@1", + "reasoning_capability": { + "status": "supported", + "control": "toggle", + "levels": ["none", "high"], + "default": "high", + "wire_format": "thinking_toggle", + "source": "catalog" + } + }, + "glm-4.7": { + "model_type": "llm", + "display_name": "GLM-4.7", + "tokenizer_family": "glm", + "capability_profile_version": "zhipu/glm-4.7@1", + "reasoning_capability": { + "status": "supported", + "control": "toggle", + "levels": ["none", "high"], + "default": "high", + "wire_format": "thinking_toggle", + "source": "catalog" + } + }, + "glm-4.5-air": { + "model_type": "llm", + "display_name": "GLM-4.5-Air", + "tokenizer_family": "glm", + "capability_profile_version": "zhipu/glm-4.5-air@1", + "reasoning_capability": { + "status": "supported", + "control": "toggle", + "levels": ["none", "high"], + "default": "high", + "wire_format": "thinking_toggle", + "source": "catalog" + } + } + } + }, + "anthropic": { + "display_name": "Anthropic Claude", + "base_url": "https://api.anthropic.com/v1/", + "model_factory": "anthropic", + "models": { + "claude-opus-4-5-20251101": { + "model_type": "llm", + "display_name": "Claude Opus 4.5", + "tokenizer_family": "claude", + "capability_profile_version": "anthropic/claude-opus-4-5@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["none", "low", "medium", "high"], + "default": "medium", + "wire_format": "thinking_budget", + "effort_budgets": {"low": 2048, "medium": 8192, "high": 16384}, + "source": "catalog" + } + }, + "claude-sonnet-4-5-20250929": { + "model_type": "llm", + "display_name": "Claude Sonnet 4.5", + "tokenizer_family": "claude", + "capability_profile_version": "anthropic/claude-sonnet-4-5@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["none", "low", "medium", "high"], + "default": "medium", + "wire_format": "thinking_budget", + "effort_budgets": {"low": 2048, "medium": 8192, "high": 16384}, + "source": "catalog" + } + }, + "claude-haiku-4-5-20251001": { + "model_type": "llm", + "display_name": "Claude Haiku 4.5", + "tokenizer_family": "claude", + "capability_profile_version": "anthropic/claude-haiku-4-5@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["none", "low", "medium", "high"], + "default": "low", + "wire_format": "thinking_budget", + "effort_budgets": {"low": 2048, "medium": 8192, "high": 16384}, + "source": "catalog" + } + } + } + }, + "google": { + "display_name": "Google Gemini", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", + "model_factory": "google", + "models": { + "gemini-3.8-flash": { + "model_type": "llm", + "display_name": "Gemini 3.8 Flash", + "tokenizer_family": "gemini", + "capability_profile_version": "google/gemini-3.8-flash@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high"], + "default": "medium", + "source": "catalog" + } + }, + "gemini-3.1-pro-preview": { + "model_type": "llm", + "display_name": "Gemini 3.1 Pro Preview", + "tokenizer_family": "gemini", + "capability_profile_version": "google/gemini-3.1-pro-preview@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high"], + "default": "high", + "source": "catalog" + } + }, + "gemini-2.5-pro": { + "model_type": "llm", + "display_name": "Gemini 2.5 Pro", + "tokenizer_family": "gemini", + "capability_profile_version": "google/gemini-2.5-pro@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high"], + "default": "high", + "source": "catalog" + } + } + } + }, + "mistral": { + "display_name": "Mistral AI", + "base_url": "https://api.mistral.ai/v1/", + "model_factory": "mistral", + "models": { + "mistral-small-latest": { + "model_type": "llm", + "display_name": "Mistral Small", + "tokenizer_family": "mistral", + "capability_profile_version": "mistral/mistral-small-latest@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["none", "high"], + "default": "high", + "source": "catalog" + } + }, + "mistral-medium-3-5": { + "model_type": "llm", + "display_name": "Mistral Medium 3.5", + "tokenizer_family": "mistral", + "capability_profile_version": "mistral/mistral-medium-3-5@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["none", "high"], + "default": "high", + "source": "catalog" + } + } + } + }, + "xai": { + "display_name": "xAI", + "base_url": "https://api.x.ai/v1/", + "model_factory": "xai", + "models": { + "grok-4.6": { + "model_type": "llm", + "display_name": "Grok 4.6", + "tokenizer_family": "grok", + "capability_profile_version": "xai/grok-4.6@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high", "xhigh"], + "default": "high", + "source": "catalog" + } + }, + "grok-4.5": { + "model_type": "llm", + "display_name": "Grok 4.5", + "tokenizer_family": "grok", + "capability_profile_version": "xai/grok-4.5@1", + "reasoning_capability": { + "status": "supported", + "control": "effort", + "levels": ["low", "medium", "high"], + "default": "high", + "source": "catalog" + } } } } diff --git a/backend/configs/model_catalog_loader.py b/backend/configs/model_catalog_loader.py index 26f742ea1c..af78648d59 100644 --- a/backend/configs/model_catalog_loader.py +++ b/backend/configs/model_catalog_loader.py @@ -13,10 +13,12 @@ import json import logging import os +import re import threading from typing import Any, Dict, Iterable, List, Optional +from urllib.parse import urlsplit, urlunsplit -from consts.const import MODEL_CATALOG_JSON_PATH +from consts.const import MODEL_CATALOG_JSON_PATH, MODELS_DEV_CATALOG_JSON_PATH from consts.model import ModelCatalogProfile, ModelCatalogProviderInfo logger = logging.getLogger("model_catalog") @@ -33,6 +35,11 @@ #: dict means "loaded but empty / file was missing". _catalog_cache: Optional[Dict[str, Any]] = None +# Raw models.dev data is kept separate from the operator-maintained Nexent +# catalog. A missing downloaded file intentionally falls back to the legacy +# resolver; an existing file is authoritative and prevents stale heuristics. +_models_dev_cache: Optional[Dict[str, Any]] = None + # ============================================================================= # Low-level load helpers @@ -78,9 +85,30 @@ def _safe_load_json(path: str) -> Dict[str, Any]: return data +def _load_models_dev_catalog(force_reload: bool = False) -> Optional[Dict[str, Any]]: + """Load the build-time models.dev snapshot, returning None when unavailable.""" + global _models_dev_cache + if _models_dev_cache is not None and not force_reload: + return _models_dev_cache + if not MODELS_DEV_CATALOG_JSON_PATH or not os.path.isfile(MODELS_DEV_CATALOG_JSON_PATH): + return None + try: + with open(MODELS_DEV_CATALOG_JSON_PATH, "r", encoding="utf-8") as fp: + data = json.load(fp) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to load models.dev catalog: %s", exc) + return None + if not isinstance(data, dict) or not isinstance(data.get("providers"), dict): + logger.warning("Ignoring malformed models.dev catalog: providers must be a mapping") + return None + _models_dev_cache = data + return data + + def _normalize_provider_models( provider_id_str: str, base_url: str, + provider_factory: Optional[str], raw_models: Any, ) -> Dict[str, ModelCatalogProfile]: """Normalize the raw ``models`` mapping of one provider into profiles.""" @@ -96,7 +124,7 @@ def _normalize_provider_models( try: profile = _build_model_profile( provider_base_url=base_url, - provider_factory=None, + provider_factory=provider_factory, model_name=model_name_str, raw=model_raw, ) @@ -121,12 +149,14 @@ def _normalize_provider(provider_id: Any, provider_raw: Any) -> Optional[Dict[st display_name = str(provider_raw.get("display_name") or provider_id_str) base_url = str(provider_raw.get("base_url") or "").strip() + provider_factory = _raw_nonempty_str(provider_raw, "model_factory") return { "display_name": display_name, "base_url": base_url, + "model_factory": provider_factory, "models": _normalize_provider_models( - provider_id_str, base_url, provider_raw.get("models") + provider_id_str, base_url, provider_factory, provider_raw.get("models") ), } @@ -263,6 +293,7 @@ def _build_model_profile( timeout_seconds=_raw_positive_int(raw, "timeout_seconds"), concurrency_limit=_raw_positive_int(raw, "concurrency_limit"), capability_profile_version=_raw_nonempty_str(raw, "capability_profile_version"), + reasoning_capability=raw.get("reasoning_capability"), requires_appid=_raw_bool(raw, "requires_appid"), requires_access_token=_raw_bool(raw, "requires_access_token"), forced_temperature=_raw_forced_temperature(raw), @@ -369,7 +400,39 @@ def get_model_profile( if not provider: return None models = provider.get("models") or {} - return models.get(str(model_name).strip()) + profile = models.get(str(model_name).strip()) + if profile is None: + return None + return _enrich_catalog_profile_reasoning(provider_id, str(model_name).strip(), profile) + + +def _enrich_catalog_profile_reasoning( + provider_id: str, + model_name: str, + profile: ModelCatalogProfile, +) -> ModelCatalogProfile: + """Overlay build-time models.dev reasoning metadata on a static profile. + + The operator-maintained catalog remains the source for capacity and other + defaults. Reasoning controls are resolved from the provider API and model + ID in the build-time snapshot so newly released models do not depend on a + second, stale static capability declaration. + """ + models_dev_catalog = _load_models_dev_catalog() + if models_dev_catalog is None: + return profile + + capability = _resolve_models_dev_reasoning_capability( + models_dev_catalog, + model_name, + profile.base_url, + profile.model_factory or provider_id, + ) + if capability is None: + return profile + profile_data = profile.model_dump(mode="python") + profile_data["reasoning_capability"] = capability + return ModelCatalogProfile.model_validate(profile_data) def list_models_by_provider( @@ -399,6 +462,7 @@ def list_models_by_provider( for model_name, profile in models.items(): if model_type and profile.model_type != str(model_type).strip(): continue + profile = _enrich_catalog_profile_reasoning(provider_id, model_name, profile) results.append( { "provider_key": provider_id, @@ -459,6 +523,232 @@ def dump_full_catalog() -> Dict[str, Any]: # Provider inference heuristics (used when user picks "OpenAI-API-Compatible") # --------------------------------------------------------------------------- + +def _canonical_api_url(value: Any) -> str: + """Normalize an API URL for exact provider matching.""" + raw = str(value or "").strip() + if not raw: + return "" + try: + parsed = urlsplit(raw) + if parsed.scheme and parsed.netloc: + return urlunsplit( + (parsed.scheme.lower(), parsed.netloc.lower(), parsed.path.rstrip("/"), "", "") + ) + except ValueError: + pass + return raw.rstrip("/").lower() + + +def _api_url_path_prefix_length(provider_url: Any, requested_url: Any) -> Optional[int]: + """Return the catalog path length when it prefixes the requested URL. + + The provider API in models.dev is often a host-level root while a user + enters a versioned endpoint such as ``/v1``. Matching only within the + same domain and requiring the catalog path to be a segment prefix keeps + different services on one host isolated. + """ + provider_api = _canonical_api_url(provider_url) + requested_api = _canonical_api_url(requested_url) + if not provider_api or not requested_api: + return None + try: + provider = urlsplit(provider_api) + requested = urlsplit(requested_api) + except ValueError: + return None + if not provider.scheme or not provider.netloc or provider.netloc != requested.netloc: + return None + provider_parts = [part for part in provider.path.split("/") if part] + requested_parts = [part for part in requested.path.split("/") if part] + if requested_parts[: len(provider_parts)] != provider_parts: + return None + return len(provider_parts) + + +def _model_id_candidates(model_key: Any, model_raw: Dict[str, Any]) -> List[str]: + """Return model IDs exposed by one models.dev model record.""" + candidates = [model_key, model_raw.get("id"), model_raw.get("model")] + result: List[str] = [] + for candidate in candidates: + value = str(candidate or "").strip() + if value and value.lower() not in {item.lower() for item in result}: + result.append(value) + return result + + +def _normalize_reasoning_options(raw_options: Any) -> List[Dict[str, Any]]: + """Normalize models.dev reasoning_options object/array variants.""" + if isinstance(raw_options, dict): + if isinstance(raw_options.get("type"), str): + raw_options = [raw_options] + else: + expanded: List[Dict[str, Any]] = [] + for option_type, option_value in raw_options.items(): + if isinstance(option_value, dict): + expanded.append({"type": option_type, **option_value}) + elif isinstance(option_value, list): + expanded.append({"type": option_type, "values": option_value}) + raw_options = expanded + elif isinstance(raw_options, list): + if all(isinstance(item, str) for item in raw_options): + raw_options = [{"type": "effort", "values": raw_options}] + else: + raw_options = [] + + return [item for item in raw_options if isinstance(item, dict)] + + +def _resolve_models_dev_reasoning_capability( + catalog: Dict[str, Any], + model_name: str, + base_url: Optional[str], + provider_hint: Optional[str], +) -> Optional[Dict[str, Any]]: + """Resolve one model by API first and model ID second.""" + requested_api = _canonical_api_url(base_url) + requested_id = str(model_name or "").strip().lower() + requested_leaf = requested_id.rsplit("/", 1)[-1] + requested_provider = str(provider_hint or "").strip().lower() + + provider_candidates: List[tuple[str, Dict[str, Any]]] = [] + prefix_candidates: List[tuple[int, str, Dict[str, Any]]] = [] + for provider_id, provider_raw in (catalog.get("providers") or {}).items(): + if not isinstance(provider_raw, dict): + continue + provider_api = _canonical_api_url(provider_raw.get("api") or provider_raw.get("base_url")) + if requested_api: + if provider_api == requested_api: + provider_candidates.append((str(provider_id), provider_raw)) + else: + prefix_length = _api_url_path_prefix_length(provider_api, requested_api) + if prefix_length is not None: + prefix_candidates.append((prefix_length, str(provider_id), provider_raw)) + elif requested_provider and str(provider_id).lower() == requested_provider: + provider_candidates.append((str(provider_id), provider_raw)) + + if not provider_candidates and requested_api: + best_prefix_length = max((item[0] for item in prefix_candidates), default=-1) + provider_candidates = [ + (provider_id, provider_raw) + for prefix_length, provider_id, provider_raw in prefix_candidates + if prefix_length == best_prefix_length + ] + + # API is the trust boundary. Do not search another provider when an API was + # supplied but its model ID is absent from the source snapshot. + if not provider_candidates: + return None + + selected: Optional[tuple[str, str, Dict[str, Any]]] = None + for provider_id, provider_raw in provider_candidates: + raw_models = provider_raw.get("models") + if not isinstance(raw_models, dict): + continue + for model_key, model_raw in raw_models.items(): + if not isinstance(model_raw, dict): + continue + ids = _model_id_candidates(model_key, model_raw) + lowered_ids = {item.lower() for item in ids} + if requested_id in lowered_ids: + selected = (provider_id, ids[0], model_raw) + break + if requested_leaf and requested_leaf in {item.rsplit("/", 1)[-1].lower() for item in ids}: + selected = (provider_id, ids[0], model_raw) + break + if selected: + break + + if selected is None: + return None + + provider_id, matched_model_id, model_raw = selected + if model_raw.get("reasoning") is not True: + return None + options = _normalize_reasoning_options(model_raw.get("reasoning_options")) + controls: List[Dict[str, Any]] = [] + effort_values: List[str] = [] + budget_range: Optional[Dict[str, int]] = None + has_toggle = False + for option in options: + option_type = str(option.get("type") or "").strip().lower() + if option_type == "toggle": + has_toggle = True + elif option_type == "effort": + values = option.get("values") or option.get("options") or [] + if isinstance(values, str): + values = [values] + effort_values.extend( + str(value).strip() for value in values + if str(value).strip() and str(value).strip() not in effort_values + ) + elif option_type == "budget_tokens": + min_value = option.get("min", option.get("min_tokens", option.get("minimum"))) + max_value = option.get("max", option.get("max_tokens", option.get("maximum"))) + try: + # models.dev uses zero as the lower bound for some providers. + min_value = 0 if min_value is None else int(min_value) + max_value = int(max_value) + except (TypeError, ValueError): + continue + if min_value >= 0 and max_value >= min_value: + budget_range = {"min": min_value, "max": max_value} + + if has_toggle: + controls.append({"type": "toggle"}) + if effort_values: + controls.append({"type": "effort", "values": effort_values}) + if budget_range: + controls.append({"type": "budget_tokens", **budget_range}) + if not controls: + return None + + provider_key = provider_id.lower() + is_dashscope = provider_key in {"alibaba", "alibaba-cn", "dashscope"} + is_deepseek = provider_key == "deepseek" + + if effort_values: + legacy_levels = [value for value in effort_values if value in { + "none", "minimal", "low", "medium", "high", "xhigh", "max" + }] + control = "effort" + wire_format = "reasoning_effort" + elif budget_range: + legacy_levels = [] + control = "budget_tokens" + wire_format = "thinking_budget" + else: + legacy_levels = [] + control = "toggle" + wire_format = "thinking_toggle" + + return { + "status": "supported", + "control": control, + "levels": legacy_levels, + "default": "auto", + "wire_format": wire_format, + "effort_budgets": {}, + "controls": controls, + "provider_id": provider_id, + # DashScope's OpenAI-compatible endpoint accepts Qwen's numeric + # control as the top-level ``thinking_budget`` field. Other provider + # profiles keep the existing nested thinking-object translation. + "budget_wire_format": "thinking_budget" if is_dashscope else None, + "toggle_wire_format": ( + "enable_thinking" if is_dashscope else "thinking_object" if is_deepseek else None + ), + "matched_api": _canonical_api_url( + next( + provider.get("api") or provider.get("base_url") + for pid, provider in provider_candidates + if pid == provider_id + ) + ), + "matched_model_id": matched_model_id, + "source": "models_dev", + } + #: Ordered candidates; first match wins. The tuple is (provider_id, url_keyword). _PROVIDER_URL_HINTS: Iterable[tuple[str, str]] = ( ("silicon", "siliconflow"), @@ -468,6 +758,12 @@ def dump_full_catalog() -> Dict[str, Any]: ("tokenpony", "tokenpony"), ("volcengine", "volces"), ("volcengine", "volcengine"), + ("deepseek", "api.deepseek.com"), + ("zhipu", "open.bigmodel.cn"), + ("anthropic", "api.anthropic.com"), + ("google", "generativelanguage.googleapis.com"), + ("mistral", "api.mistral.ai"), + ("xai", "api.x.ai"), ("openai", "api.openai.com"), ("modelengine", "modelengine"), ) @@ -489,6 +785,192 @@ def infer_provider_from_base_url(base_url: str) -> Optional[str]: return None +def resolve_reasoning_capability( + model_name: str, + base_url: Optional[str] = None, + provider_hint: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Resolve reasoning capability from an exact profile or safe ID family. + + Exact catalog entries are authoritative. When an older/custom row is not + an exact key, deterministic vendor/model-ID rules cover known families so + newly released IDs do not lose the selector until the catalog is updated. + Unknown IDs still return ``None``. + """ + if not model_name: + return None + + models_dev_catalog = _load_models_dev_catalog() + if models_dev_catalog is not None: + return _resolve_models_dev_reasoning_capability( + models_dev_catalog, + model_name, + base_url, + provider_hint, + ) + + provider_id = str(provider_hint or "").strip() + if not get_provider_info(provider_id): + provider_id = infer_provider_from_base_url(str(base_url or "")) or "" + model_name_lower = str(model_name).strip().lower() + model_leaf = model_name_lower.rsplit("/", 1)[-1] + + # A provider factory is not always persisted for older/custom rows. Infer + # the vendor from the model id before falling back to an OpenAI-compatible + # URL. This keeps historical rows discoverable after the catalog grows. + if not provider_id: + provider_id = _infer_provider_from_model_id(model_leaf) or "" + + inferred_provider = _infer_provider_from_model_id(model_leaf) + + if provider_id: + profile = get_model_profile(provider_id, model_name) + if profile is not None: + # An explicit catalog entry without reasoning metadata is an + # intentional unsupported declaration; do not override it with a + # broad name heuristic. + if profile.reasoning_capability is None: + return None + return profile.reasoning_capability.model_dump(mode="json") + + heuristic_provider = provider_id + if provider_id in { + "", + "custom", + "OpenAI-API-Compatible", + "silicon", + "modelengine", + } and inferred_provider: + heuristic_provider = inferred_provider + return _infer_reasoning_capability_from_model_id(model_leaf, heuristic_provider) + + +def _infer_provider_from_model_id(model_id: str) -> Optional[str]: + """Infer a known vendor from a model id when an old row lacks provider data.""" + value = str(model_id or "").lower() + if "deepseek" in value: + return "deepseek" + if "claude" in value: + return "anthropic" + if "gemini" in value: + return "google" + if "grok" in value: + return "xai" + if re.match(r"^(?:o[1-4](?:[-.]|$)|gpt-5(?:[-.]|$))", value): + return "openai" + if re.match(r"^(?:glm|chatglm)[-_]", value): + return "zhipu" + if "qwen" in value or "qwq" in value: + return "dashscope" + if "mistral" in value or "magistral" in value: + return "mistral" + return None + + +def _reasoning_effort_capability( + levels: List[str], default: str, wire_format: str = "reasoning_effort" +) -> Dict[str, Any]: + return { + "status": "supported", + "control": "effort", + "levels": levels, + "default": default, + "wire_format": wire_format, + "source": "operator", + } + + +def _resolve_deepseek_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.search(r"deepseek[-_]?v4", value) or "deepseek-reasoner" in value: + return _reasoning_effort_capability(["low", "high", "max"], "high") + return None + + +def _resolve_zhipu_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.match(r"(?:glm|chatglm)[-_]?(?:4\.[5-9]|5)", value): + return { + "status": "supported", + "control": "toggle", + "levels": ["none", "high"], + "default": "high", + "wire_format": "thinking_toggle", + "source": "operator", + } + return None + + +def _resolve_anthropic_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.search(r"claude[-_].*4", value): + return _reasoning_effort_capability( + ["none", "low", "medium", "high"], "medium", "thinking_budget" + ) | {"effort_budgets": {"low": 2048, "medium": 8192, "high": 16384}} + return None + + +def _resolve_google_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.search(r"gemini[-_](?:2\.5|3|4)", value): + return _reasoning_effort_capability(["low", "medium", "high"], "high") + return None + + +def _resolve_openai_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.match(r"(?:o[1-4]|gpt-5)(?:[-.]|$)", value): + levels = ( + ["low", "medium", "high"] + if value.startswith("o") + else ["minimal", "low", "medium", "high"] + ) + return _reasoning_effort_capability(levels, "medium") + return None + + +def _resolve_xai_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.match(r"grok[-_]4", value): + return _reasoning_effort_capability(["low", "medium", "high", "xhigh"], "high") + return None + + +def _resolve_qwen_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.search(r"qwen[-_]?3", value) or "qwq" in value: + return _reasoning_effort_capability(["low", "medium", "xhigh"], "medium") + return None + + +def _resolve_mistral_reasoning(value: str) -> Optional[Dict[str, Any]]: + if re.search(r"(?:magistral|mistral[-_]medium|mistral[-_]large)", value): + return _reasoning_effort_capability(["none", "high"], "high") + return None + + +_REASONING_CAPABILITY_RESOLVERS = { + "deepseek": _resolve_deepseek_reasoning, + "zhipu": _resolve_zhipu_reasoning, + "anthropic": _resolve_anthropic_reasoning, + "google": _resolve_google_reasoning, + "openai": _resolve_openai_reasoning, + "xai": _resolve_xai_reasoning, + "dashscope": _resolve_qwen_reasoning, + "silicon": _resolve_qwen_reasoning, + "modelengine": _resolve_qwen_reasoning, + "mistral": _resolve_mistral_reasoning, +} + + +def _infer_reasoning_capability_from_model_id( + model_id: str, + provider_id: str, +) -> Optional[Dict[str, Any]]: + """Return a conservative capability for recognized model-id families. + + Exact catalog entries always win. This fallback is for provider model IDs + introduced after the bundled catalog version and for historical custom + rows whose provider/model name was not an exact catalog key. + """ + value = str(model_id or "").lower() + resolver = _REASONING_CAPABILITY_RESOLVERS.get(str(provider_id or "").lower()) + return resolver(value) if resolver else None + + # --------------------------------------------------------------------------- # Apply catalog defaults to user model_data (used by service layer) # --------------------------------------------------------------------------- diff --git a/backend/consts/agent.py b/backend/consts/agent.py index b0fb91dfdf..52df3da4ed 100644 --- a/backend/consts/agent.py +++ b/backend/consts/agent.py @@ -1,3 +1,7 @@ """Constants used by agent execution and streaming.""" SAFE_AGENT_STREAM_ERROR_MESSAGE = "Agent execution failed. Please try again later." +SAFE_REASONING_CONFIGURATION_ERROR_MESSAGE = ( + "思考挡位参数配置错误:当前模型或服务商不支持所选挡位,请在模型高级设置中关闭思考挡位开关或选择支持的挡位。" +) +REASONING_CONFIGURATION_ERROR_CODE = "MODEL_REASONING_CONFIG_INVALID" diff --git a/backend/consts/const.py b/backend/consts/const.py index 2760cd853b..5f97fe86b6 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -3,12 +3,20 @@ from pathlib import Path from dotenv import load_dotenv +try: + from backend.utils.config_validation import parse_positive_float, parse_positive_int +except ModuleNotFoundError: + # The production service runs with ``backend`` as the working directory, + # where backend-local modules are imported from the top-level package. + from utils.config_validation import parse_positive_float, parse_positive_int + # Load environment variables # Explicitly sourced deployment variables take precedence over a nearby # developer .env file. This is required for tmux/K8s-local verification and # avoids silently replacing operator-provided service addresses. load_dotenv(override=False) + # TODO: Analyze every variable if this is used # Test voice file path (WAV format for volcengine STT) TEST_VOICE_PATH = os.path.join(os.path.dirname( @@ -43,6 +51,11 @@ class VectorDatabaseType(str, Enum): # Upload Configuration MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB +# Knowledge-base uploads have an independently configurable hard ceiling. +MAX_KNOWLEDGE_FILE_SIZE_MB = parse_positive_int( + os.getenv("MAX_KNOWLEDGE_FILE_SIZE_MB"), "MAX_KNOWLEDGE_FILE_SIZE_MB", 100 +) +MAX_KNOWLEDGE_FILE_SIZE_BYTES = MAX_KNOWLEDGE_FILE_SIZE_MB * 1024 * 1024 MAX_CONCURRENT_UPLOADS = 5 UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER', 'uploads') AGENT_WORKSPACE_ROOT = os.getenv('AGENT_WORKSPACE_ROOT', '/mnt/nexent/workdir') @@ -97,7 +110,18 @@ class VectorDatabaseType(str, Enum): CONTAINER_SKILLS_PATH = os.getenv("SKILLS_PATH") # Container-internal official skills ZIP directory -OFFICIAL_SKILLS_ZIP_PATH = "/mnt/nexent/official-skills-zip" +OFFICIAL_SKILLS_ZIP_PATH = os.getenv( + "OFFICIAL_SKILLS_ZIP_PATH", "/mnt/nexent/official-skills-zip" +) + +# Container-internal official agents bundle directory (one JSON per agent) +OFFICIAL_AGENTS_PATH = os.getenv( + "OFFICIAL_AGENTS_PATH", "/mnt/nexent/official-agents" +) + +OFFICIAL_AGENT_PROFILES = os.getenv("OFFICIAL_AGENT_PROFILES", "") +SYSTEM_TENANT_ID = "system" +SYSTEM_USER_ID = "system" # Preview Configuration @@ -199,12 +223,49 @@ class VectorDatabaseType(str, Enum): DEFAULT_USER_ID = "user_id" DEFAULT_TENANT_ID = "tenant_id" -# Tenant resource hard limits. These values are intentionally not configurable. -MAX_TENANT_COUNT = 100 -MAX_USERS_PER_TENANT = 10_000 -MAX_GROUPS_PER_TENANT = 1_000 -MAX_SUPER_ADMIN_COUNT = 1 -MAX_ADMINS_PER_TENANT = 1_000 +# Tenant resource hard limits. Environment variables override these defaults. +MAX_TENANT_COUNT = parse_positive_int(os.getenv("MAX_TENANT_COUNT"), "MAX_TENANT_COUNT", 100) +MAX_USERS_PER_TENANT = parse_positive_int(os.getenv("MAX_USERS_PER_TENANT"), "MAX_USERS_PER_TENANT", 10_000) +MAX_GROUPS_PER_TENANT = parse_positive_int(os.getenv("MAX_GROUPS_PER_TENANT"), "MAX_GROUPS_PER_TENANT", 1_000) +MAX_SUPER_ADMIN_COUNT = parse_positive_int(os.getenv("MAX_SUPER_ADMIN_COUNT"), "MAX_SUPER_ADMIN_COUNT", 1) +MAX_ADMINS_PER_TENANT = parse_positive_int(os.getenv("MAX_ADMINS_PER_TENANT"), "MAX_ADMINS_PER_TENANT", 1_000) +MAX_KNOWLEDGE_BASES_PER_TENANT = parse_positive_int( + os.getenv("MAX_KNOWLEDGE_BASES_PER_TENANT"), "MAX_KNOWLEDGE_BASES_PER_TENANT", 10_000 +) +MAX_KNOWLEDGE_BASES_PER_USER = parse_positive_int( + os.getenv("MAX_KNOWLEDGE_BASES_PER_USER"), "MAX_KNOWLEDGE_BASES_PER_USER", 10 +) +MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER = parse_positive_int( + os.getenv("MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER"), + "MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER", + 1_000, +) +MAX_CONVERSATION_TURNS = parse_positive_int( + os.getenv("MAX_CONVERSATION_TURNS"), "MAX_CONVERSATION_TURNS", 100 +) +MAX_CONVERSATIONS_PER_USER = parse_positive_int( + os.getenv("MAX_CONVERSATIONS_PER_USER"), "MAX_CONVERSATIONS_PER_USER", 1_000 +) +MAX_AGENTS_PER_TENANT = parse_positive_int( + os.getenv("MAX_AGENTS_PER_TENANT"), "MAX_AGENTS_PER_TENANT", 1_000 +) + +# Skill resource hard limits. Environment variables override these defaults. +MAX_SKILLS_PER_TENANT = parse_positive_int( + os.getenv("MAX_SKILLS_PER_TENANT"), "MAX_SKILLS_PER_TENANT", 1_000 +) +MAX_SKILL_UPLOAD_SIZE_MB = parse_positive_int( + os.getenv("MAX_SKILL_UPLOAD_SIZE_MB"), "MAX_SKILL_UPLOAD_SIZE_MB", 10 +) +MAX_SKILL_UPLOAD_SIZE_BYTES = MAX_SKILL_UPLOAD_SIZE_MB * 1024 * 1024 + +# Evaluation-set Excel uploads. +MAX_EVALUATION_SET_FILE_SIZE_MB = parse_positive_int( + os.getenv("MAX_EVALUATION_SET_FILE_SIZE_MB"), + "MAX_EVALUATION_SET_FILE_SIZE_MB", + 20, +) +MAX_EVALUATION_SET_FILE_SIZE_BYTES = MAX_EVALUATION_SET_FILE_SIZE_MB * 1024 * 1024 # Invitation code type for asset administrator registration ASSET_OWNER_INVITE_CODE_TYPE = "ASSET_OWNER_INVITE" @@ -250,6 +311,8 @@ class VectorDatabaseType(str, Enum): # AIDP Knowledge Base configuration ENABLE_AIDP_KNOWLEDGE = os.getenv("ENABLE_AIDP_KNOWLEDGE", "false").lower() in ("true", "1", "yes", "on") +ENABLE_AGENT_WORKBENCH = os.getenv("ENABLE_AGENT_WORKBENCH", "false").lower() in ("true", "1", "yes", "on") +HIDE_HOME_PAGE = os.getenv("HIDE_HOME_PAGE", "false").lower() in ("true", "1", "yes", "on") AIDP_SERVER_URL = os.getenv("AIDP_SERVER_URL", "") AIDP_API_KEY = os.getenv("AIDP_API_KEY", "") AIDP_TENANT_ID = os.getenv("AIDP_TENANT_ID", "aidp") @@ -305,6 +368,11 @@ class VectorDatabaseType(str, Enum): RUNTIME_MCP_TOOL_TIMEOUT_SECONDS = float( os.getenv("RUNTIME_MCP_TOOL_TIMEOUT_SECONDS", "60") ) +RUNTIME_PARALLEL_EXECUTOR_TIMEOUT_SECONDS = int( + os.getenv("RUNTIME_PARALLEL_EXECUTOR_TIMEOUT_SECONDS", "120") +) +if RUNTIME_PARALLEL_EXECUTOR_TIMEOUT_SECONDS <= 0: + raise ValueError("RUNTIME_PARALLEL_EXECUTOR_TIMEOUT_SECONDS must be greater than zero") RUNTIME_MCP_CLOSE_TIMEOUT_SECONDS = float( os.getenv("RUNTIME_MCP_CLOSE_TIMEOUT_SECONDS", "5") ) @@ -509,6 +577,19 @@ class VectorDatabaseType(str, Enum): # MCP Server LOCAL_MCP_SERVER = os.getenv("NEXENT_MCP_SERVER") MCP_MANAGEMENT_API = os.getenv("MCP_MANAGEMENT_API", "http://localhost:5015") +# Maximum number of configured MCP services per tenant. +MAX_MCP_SERVICES_PER_TENANT = parse_positive_int( + os.getenv("MAX_MCP_SERVICES_PER_TENANT"), + "MAX_MCP_SERVICES_PER_TENANT", + 1_000, +) +# Maximum time allowed to establish a connection and complete the MCP +# initialization handshake. This does not limit tool execution time. +MCP_REQUEST_TIMEOUT_SECONDS = parse_positive_float( + os.getenv("MCP_REQUEST_TIMEOUT_SECONDS"), + "MCP_REQUEST_TIMEOUT_SECONDS", + 10, +) # Invite code @@ -848,5 +929,11 @@ def _resolve_app_version(default: str = "v2.2.1") -> str: ) """Nexent 预置模型目录 (JSON) 文件路径。可通过环境变量覆盖。""" +MODELS_DEV_CATALOG_JSON_PATH = os.getenv( + "MODELS_DEV_CATALOG_JSON_PATH", + os.path.join(os.path.dirname(__file__), "..", "configs", "models_dev_catalog.json") +) +"""models.dev capability catalog downloaded during the backend image build.""" + # External Memory Provider Configuration MEMORY_PROVIDER_PLUGINS_DIR = os.getenv("MEMORY_PROVIDER_PLUGINS_DIR", "") diff --git a/backend/consts/error_code.py b/backend/consts/error_code.py index 9872d16fc8..75aebdcdb5 100644 --- a/backend/consts/error_code.py +++ b/backend/consts/error_code.py @@ -112,6 +112,7 @@ class ErrorCode(Enum): KNOWLEDGE_STORAGE_COMMIT_FAILED = "060107" # Source object ledger commit failed KNOWLEDGE_TASK_SUBMIT_FAILED = "060108" # Data-process task submission failed KNOWLEDGE_DELETE_BLOCKED = "060109" # Delete blocked by in-flight file processing + KNOWLEDGE_RESOURCE_EXCEEDED = "060110" # Knowledge resource limit exceeded # ==================== 07 MCPTools / MCP 工具 ==================== # 01 - Tool @@ -300,18 +301,20 @@ class ErrorCode(Enum): ErrorCode.COMMON_MISSING_REQUIRED_FIELD: 400, # Common - Rate Limit ErrorCode.COMMON_RATE_LIMIT_EXCEEDED: 429, + # Tenant resource quotas + ErrorCode.TENANT_RESOURCE_EXCEEDED: 429, # Common - Resource ErrorCode.COMMON_RESOURCE_NOT_FOUND: 404, ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS: 409, ErrorCode.COMMON_RESOURCE_DISABLED: 403, # Knowledge base lifecycle ErrorCode.KNOWLEDGE_DELETE_BLOCKED: 409, + ErrorCode.KNOWLEDGE_RESOURCE_EXCEEDED: 429, # Chat - Runtime metadata ErrorCode.CHAT_METADATA_NOT_ALLOWED: 400, ErrorCode.CHAT_METADATA_INVALID: 422, ErrorCode.CHAT_METADATA_TOO_LARGE: 413, ErrorCode.CHAT_METADATA_VERSION_CONFLICT: 409, - # Tenant resource - personal KB quota ErrorCode.TENANT_PERSONAL_KB_QUOTA_EXCEEDED: 403, ErrorCode.TENANT_PERSONAL_KB_QUOTA_UNAVAILABLE: 503, ErrorCode.TENANT_PERSONAL_KB_QUOTA_BELOW_USAGE: 400, diff --git a/backend/consts/error_message.py b/backend/consts/error_message.py index 433122ea47..f53befb6d8 100644 --- a/backend/consts/error_message.py +++ b/backend/consts/error_message.py @@ -76,6 +76,7 @@ class ErrorMessage: ErrorCode.KNOWLEDGE_STORAGE_COMMIT_FAILED: "File upload failed because the storage service is unavailable.", ErrorCode.KNOWLEDGE_TASK_SUBMIT_FAILED: "The file was uploaded, but the ingestion service is unavailable.", ErrorCode.KNOWLEDGE_DELETE_BLOCKED: "Knowledge base deletion is blocked while files are being processed.", + ErrorCode.KNOWLEDGE_RESOURCE_EXCEEDED: "Knowledge resource limit exceeded.", # ==================== 07 MCPTools / MCP 工具 ==================== ErrorCode.MCP_TOOL_NOT_FOUND: "Tool not found.", @@ -176,7 +177,7 @@ class ErrorMessage: ErrorCode.AGENT_EVALUATION_QUERY_COUNT_RANGE: "Query count must be between 1 and 50.", ErrorCode.AGENT_EVALUATION_AGENT_NOT_FOUND: "Agent not found.", ErrorCode.AGENT_EVALUATION_JUDGE_MODEL_REQUIRED: "Judge model ID is required.", - ErrorCode.AGENT_EVALUATION_ONLY_CREATOR_CAN_DELETE: "Only the creator can delete this evaluation run.", + ErrorCode.AGENT_EVALUATION_ONLY_CREATOR_CAN_DELETE: "Only the creator or a tenant administrator can delete this evaluation run.", ErrorCode.AGENT_EVALUATION_QUERY_GENERATION_FAILED: "Failed to generate test queries.", ErrorCode.AGENT_EVALUATION_QUERY_GENERATION_FORMAT: "AI returned invalid format for test queries.", ErrorCode.AGENT_EVALUATION_QUERY_GENERATION_EMPTY: "AI generated no valid test queries.", diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index c39d5a692c..92ba3f283f 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -238,10 +238,65 @@ def __init__(self, current_version: int): super().__init__("Runtime metadata version conflict") +class WorkbenchError(ValidationError, ValueError): + """Safe Workbench boundary error without resource contents or credentials.""" + + def __init__(self, code: str, status_code: int = 422): + self.code = code + self.status_code = status_code + super().__init__(code) + + +class WorkbenchConfigVersionConflict(ValueError): + """Raised when a Workbench optimistic-lock check fails.""" + + def __init__(self, current_version: int, current_config: Optional[dict] = None): + self.current_version = current_version + self.current_config = current_config + super().__init__("Workbench configuration version conflict") + + class TenantResourceLimitError(ValidationError, ValueError): """Raised when a platform or tenant hard resource limit is reached.""" - pass + code = ErrorCode.TENANT_RESOURCE_EXCEEDED.value + def __init__( + self, + message: str, + *, + resource: str | None = None, + scope: str | None = None, + limit: int | None = None, + current_count: int | None = None, + ): + self.resource = resource + self.scope = scope + self.limit = limit + self.current_count = current_count + self.details = self.to_detail() + super().__init__(message) + + def to_detail(self) -> dict: + """Return structured quota details for the standard API error contract.""" + return { + key: value + for key, value in { + "resource": self.resource, + "scope": self.scope, + "limit": self.limit, + "current_count": self.current_count, + }.items() + if value is not None + } + + +def tenant_resource_limit_error_payload(error: TenantResourceLimitError) -> dict: + """Build the standard API error payload for a tenant resource limit.""" + return { + "code": ErrorCode.TENANT_RESOURCE_EXCEEDED.value, + "message": str(error), + "details": error.to_detail(), + } class NotFoundException(Exception): @@ -321,6 +376,40 @@ class SkillException(Exception): pass +class WorkbenchAgentError(Exception): + """Stable domain error raised by the system Agent provider.""" + + def __init__( + self, + code: str, + *, + retryable: bool = False, + message: Optional[str] = None, + ): + super().__init__(message or code) + self.code = code + self.message_key = code + self.retryable = retryable + self.resource_type = "agent" + + +class RuntimeSubAgentError(ValueError): + """Stable validation and authorization error for runtime adapters.""" + + def __init__( + self, + code: str, + *, + retryable: bool = False, + message: Optional[str] = None, + ): + super().__init__(message or code) + self.code = code + self.message_key = code + self.retryable = retryable + self.resource_type = "runtime_sub_agent" + + class QuotaExceededError(Exception): """Raised when tenant storage hard limit is exceeded during file upload.""" diff --git a/backend/consts/model.py b/backend/consts/model.py index a71211ca6f..98723da471 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -578,6 +578,23 @@ class ModelRequest(BaseModel): # forwards them to model_capacity_suggestion_accept_total. accepted_suggestion_match_kind: Optional[str] = None accepted_capability_profile_version: Optional[str] = None + # Batch-import flow control (never persisted). The batch dialog creates + # rows one HTTP call at a time; rows marked skip_default_backfill leave + # default-model slots untouched so a single finalize call (after the + # loop) can fill empty slots from the whole batch at once. Popped by + # the app layer before the dict reaches the service/DB layer. + skip_default_backfill: Optional[bool] = None + + +class BackfillDefaultsRequest(BaseModel): + """Request payload for POST /model/backfill_defaults only. + + Finalizes default-model auto-configuration after a batch import: empty + slots are filled from the best model among the given display names. + Occupied slots (user- or system-configured) are never touched. + """ + display_names: List[str] = Field( + ..., description="Display names of the models created in the batch") class ModelProbeRequest(ModelRequest): @@ -615,6 +632,7 @@ class ModelCapacitySuggestionRequest(BaseModel): class ModelCapacitySuggestionResponse(BaseModel): suggestions: Optional[CapacitySuggestionFields] = None + reasoning_capability: Optional["ReasoningCapability"] = None match_kind: Literal["catalog_exact", "catalog_fuzzy", "provider_discovery", "litellm_lookup", "none"] match_confidence: Optional[Literal["high", "medium", "low"]] = None match_explanation: str @@ -815,6 +833,18 @@ class ConversationKnowledgeScopeRequest(BaseModel): """Persisted business policy for conversation-scoped knowledge retrieval.""" schema_version: Literal[1] = 1 + retrieval_config: Optional[Dict[str, Any]] = None + + @field_validator("retrieval_config") + @classmethod + def validate_retrieval_config(cls, values): + if values is None: + return None + reserved = {"index_names", "kds_list", "display_names", "server_url", "api_key", "tenant_id", + "observer", "kds_name_to_id_map", "allowed_kds_set", "allowed_index_names"} + if len(values) > 30 or reserved.intersection(values): + raise ValueError("Retrieval parameters cannot contain connection settings or resource ranges") + return values local: LocalKnowledgeScopeRequest = Field(default_factory=LocalKnowledgeScopeRequest) aidp: AidpKnowledgeScopeRequest = Field(default_factory=AidpKnowledgeScopeRequest) @@ -823,6 +853,100 @@ class ConversationKnowledgeScopeUpdateRequest(BaseModel): """Replace a conversation scope, or clear it with null to restore defaults.""" scope: Optional[ConversationKnowledgeScopeRequest] = None + expected_workbench_config_version: Optional[int] = Field(default=None, ge=0) + + +WorkbenchMode = Literal[ + "generic_chat", + "single_agent_chat", + "multi_agent_chat", + "skill_create", + "agent_create", +] + + +class RuntimeAgentMount(BaseModel): + """Stable reference to one Agent version selected by the Workbench.""" + + model_config = ConfigDict(extra="forbid") + + agent_id: int = Field(gt=0) + version_no: Optional[int] = Field(default=None, gt=0) + + +class RuntimeSkillMount(BaseModel): + """Complete root-Agent Skill selection for a Workbench conversation.""" + + model_config = ConfigDict(extra="forbid") + + skill_id: int = Field(gt=0) + config_values: Dict[str, Any] = Field(default_factory=dict) + + +class WorkbenchGenerationConfig(BaseModel): + """Provider-neutral generation settings persisted with the conversation.""" + + model_config = ConfigDict(extra="forbid") + + deep_thinking: bool = False + thinking_effort: Literal["low", "medium", "high"] = "low" + temperature: Optional[float] = Field(default=None, ge=0, le=2) + top_p: Optional[float] = Field(default=None, gt=0, le=1) + requested_output_tokens: Optional[int] = Field(default=None, gt=0) + + +class WorkbenchSessionConfig(BaseModel): + """Canonical, persisted Workbench declaration; resolved artifacts are excluded.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[3] = 3 + mode: WorkbenchMode + model_id: Optional[int] = Field(default=None, gt=0) + generation_config: WorkbenchGenerationConfig = Field( + default_factory=WorkbenchGenerationConfig + ) + agent_mounts: List[RuntimeAgentMount] = Field(default_factory=list, max_length=8) + skill_mounts: List[RuntimeSkillMount] = Field(default_factory=list, max_length=20) + knowledge_scope: Optional[ConversationKnowledgeScopeRequest] = None + + @model_validator(mode="after") + def validate_mode_resources(self): + agent_count = len(self.agent_mounts) + if self.mode == "generic_chat" and agent_count != 0: + raise ValueError("generic_chat does not accept Agent mounts") + if self.mode == "single_agent_chat" and agent_count != 1: + raise ValueError("single_agent_chat requires exactly one Agent mount") + if self.mode == "multi_agent_chat" and agent_count < 2: + raise ValueError("multi_agent_chat requires at least two Agent mounts") + if self.mode in {"skill_create", "agent_create"}: + if agent_count or self.skill_mounts or self.knowledge_scope is not None: + raise ValueError("creation modes do not accept Agent, Skill, or knowledge resources") + skill_ids = [mount.skill_id for mount in self.skill_mounts] + if len(skill_ids) != len(set(skill_ids)): + raise ValueError("skill_mounts contains duplicate skill_id values") + agent_keys = [(mount.agent_id, mount.version_no) for mount in self.agent_mounts] + if len(agent_keys) != len(set(agent_keys)): + raise ValueError("agent_mounts contains duplicate Agent references") + return self + + +class WorkbenchConfigUpdateRequest(BaseModel): + """Optimistic-lock replacement request for an existing conversation.""" + + model_config = ConfigDict(extra="forbid") + + config: WorkbenchSessionConfig + expected_version: int = Field(ge=0) + + +class WorkbenchCapabilityPreviewRequest(BaseModel): + """Request defaults and capabilities for one candidate published Agent.""" + + model_config = ConfigDict(extra="forbid") + + agent_id: int = Field(gt=0) + version_no: Optional[int] = Field(default=None, gt=0) def reject_legacy_agent_fields(value): @@ -840,11 +964,30 @@ class AgentRequest(BaseModel): minio_files: Optional[List[Dict[str, Any]]] = None agent_id: Optional[int] = None model_id: Optional[int] = None + generation_config: Optional[WorkbenchGenerationConfig] = None + reasoning_effort: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = Field( + default=None, + description=( + "Optional per-run reasoning effort. None inherits the selected model " + "or Agent default." + ), + ) + reasoning_budget_tokens: Optional[int] = Field( + default=None, + gt=0, + description=( + "Optional per-run reasoning token budget. None inherits the selected " + "model or Agent default." + ), + ) requested_output_tokens: Optional[int] = Field(default=None, gt=0) version_no: Optional[int] = None is_debug: Optional[bool] = False tool_params: Optional[ToolParamsRequest] = None knowledge_scope: Optional[ConversationKnowledgeScopeRequest] = None + entrypoint: Optional[Literal["workbench"]] = None + workbench: Optional[WorkbenchSessionConfig] = None + expected_workbench_config_version: Optional[int] = Field(default=None, ge=0) context_policy: Optional[Dict[str, Any]] = Field( default=None, description="Optional request-scoped context policy override", @@ -869,6 +1012,14 @@ def reject_legacy_execution_controls(cls, value): def validate_context_policy(cls, value): return _validated_context_policy(value) + @model_validator(mode="after") + def validate_workbench_entrypoint(self): + if self.entrypoint == "workbench" and self.workbench is None and self.conversation_id is None: + raise ValueError("workbench entrypoint requires a Workbench configuration") + if self.workbench is not None and self.entrypoint != "workbench": + raise ValueError("Workbench configuration requires entrypoint='workbench'") + return self + enable_plan: Optional[bool] = Field( default=False, description="Whether to enable the planning phase before execution" @@ -880,19 +1031,30 @@ def validate_context_policy(cls, value): class NL2AgentRunRequest(BaseModel): - """Request payload for one ephemeral NL2Agent turn.""" + """Request payload for an NL2Agent turn, optionally persisted by Workbench.""" query: str = Field(min_length=1) history: Optional[List[HistoryItem]] = None minio_files: Optional[List[Dict[str, Any]]] = None agent_id: int = Field(gt=0) + conversation_id: Optional[int] = Field(default=None, gt=0) + retry_user_message_id: Optional[int] = Field(default=None, gt=0) + retry_message_index: Optional[int] = Field(default=None, ge=0) + persist_history: bool = False + workbench_config: Optional[Dict[str, Any]] = None class NL2SkillRunRequest(BaseModel): - """Request payload for one ephemeral NL2Skill conversation turn.""" + """Request payload for an NL2Skill turn, optionally persisted by Workbench.""" query: str = Field(min_length=1) history: Optional[List[HistoryItem]] = None + minio_files: Optional[List[Dict[str, Any]]] = None + conversation_id: Optional[int] = Field(default=None, gt=0) + retry_user_message_id: Optional[int] = Field(default=None, gt=0) + retry_message_index: Optional[int] = Field(default=None, ge=0) + persist_history: bool = False + workbench_config: Optional[Dict[str, Any]] = None draft_snapshot: Optional[Dict[str, Any]] = None complexity: Literal["simple", "complicated"] = "complicated" language: Optional[Literal["zh", "en"]] = None @@ -1180,6 +1342,7 @@ class AgentInfoRequest(BaseModel): group_ids: Optional[List[int]] = None ingroup_permission: Optional[str] = None enable_context_manager: Optional[bool] = None + enable_protocol_repair_retry: Optional[bool] = None is_a2a: Optional[bool] = None verification_config: Optional[Dict[str, Any]] = None context_policy: Optional[Dict[str, Any]] = None @@ -1282,6 +1445,7 @@ class ExportAndImportAgentInfo(BaseModel): is_main_agent: bool = True provide_run_summary: bool allow_chat_metadata: bool = False + enable_protocol_repair_retry: bool = False verification_config: Optional[Dict[str, Any]] = None context_policy: Optional[Dict[str, Any]] = None duty_prompt: Optional[str] = None @@ -1327,6 +1491,157 @@ class AgentRepositorySnapshot(ExportAndImportDataFormat): skills: Optional[List["SkillZipEntry"]] = None +# --------------------------------------------------------------------------- +# Official agent bundles (platform-provided, mirroring official skills) +# --------------------------------------------------------------------------- + + +class KnowledgeBaseSeedDoc(BaseModel): + """A seed document in an official agent bundle's knowledge base. + + Text seeds carry ``content``; binary seeds (docx/pdf/...) carry ``file_path`` + pointing at the real file on disk (set by the loader for directory layouts, + so the install pipeline can upload it like a normal KB document). At least + one of the two is set. + """ + file_name: str + content: Optional[str] = None + file_path: Optional[str] = None + + +class KnowledgeBaseSeed(BaseModel): + """Knowledge base declaration inside an official agent bundle. + + ``logical_index_name`` is the bundle-local reference that agent tools point + to; it is remapped to the tenant's real generated index name on install. + """ + logical_index_name: str + display_name: Optional[str] = None + description: Optional[str] = None + documents: List[KnowledgeBaseSeedDoc] = [] + + +class OfficialAgentBundle(AgentRepositorySnapshot): + """Official agent bundle: marketplace snapshot plus official card fields. + + Reuses AgentRepositorySnapshot (agent_info / mcp_info / skills) and adds + official card metadata plus optional knowledge base seed documents. + An empty ``knowledge_bases`` list means the agent has no KB dependency. + + Card fields are optional: when omitted they are derived from the root agent + (name / display_name) or sensible defaults (icon, version_label), so a bare + export can be used directly as a bundle without manual card editing. + """ + name: Optional[str] = None + display_name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + tags: List[str] = [] + version_label: Optional[str] = None + knowledge_bases: List[KnowledgeBaseSeed] = [] + + @model_validator(mode="after") + def _derive_card_fields(self) -> "OfficialAgentBundle": + root_agent = self.agent_info.get(str(self.agent_id)) + root_name = getattr(root_agent, "name", None) if root_agent else None + root_display_name = ( + getattr(root_agent, "display_name", None) if root_agent else None + ) + if not self.name: + self.name = root_name or "agent" + if not self.display_name: + self.display_name = root_display_name or self.name + if not self.icon: + self.icon = "🤖" + if not self.version_label: + self.version_label = "V1" + return self + + +OfficialAgentStatus = Literal[ + "installed", "needs_model", "installable" +] + + +class OfficialAgentAgentInfo(BaseModel): + """An agent inside an official bundle (root or sub-agent) for conflict pre-check.""" + name: str + display_name: Optional[str] = None + + +class OfficialAgentMcpPreview(BaseModel): + """MCP server declaration inside an official bundle, with per-tenant install state.""" + mcp_server_name: str + mcp_url: str + installed: bool = False + + +class OfficialAgentListItem(BaseModel): + """Single item in the GET /repository/agent/official response.""" + name: str + display_name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + tags: List[str] = [] + version_label: Optional[str] = None + status: OfficialAgentStatus + has_knowledge: bool + mcp_count: int + skill_count: int + kb_count: int + missing_models: List[str] = [] + agents: List[OfficialAgentAgentInfo] = [] + mcps: List[OfficialAgentMcpPreview] = [] + + +OfficialAgentInstallStatus = Literal[ + "installed", "needs_model", "already_installed", "not_found", "failed" +] + +OfficialAgentInstallStepStatus = Literal["ok", "failed"] + + +class OfficialAgentInstallStep(BaseModel): + """One step of an official agent install (mcp / tools / knowledge_base / agent). + + ``status`` is "ok" when the step completed, "failed" when it raised (the + install aborts and the failed step's message explains why). + """ + name: str + status: OfficialAgentInstallStepStatus + message: Optional[str] = None + + +class OfficialAgentInstallRequest(BaseModel): + """Request body for installing official agents. + + ``renames`` maps an existing agent name inside a bundle to a new name + (used to resolve name conflicts before import). ``model_ids`` maps a bundle + key to a tenant LLM model_id applied to the bundle's root agent on install. + """ + agent_names: List[str] = Field( + ..., min_length=1, description="Official agent bundle names to install" + ) + renames: Optional[Dict[str, str]] = None + model_ids: Optional[Dict[str, int]] = None + embedding_model_ids: Optional[Dict[str, int]] = None + + +class OfficialAgentInstallItem(BaseModel): + """Per-agent result of an official agent install request.""" + name: str + status: OfficialAgentInstallStatus + message: Optional[str] = None + steps: Optional[List[OfficialAgentInstallStep]] = None + missing_models: List[str] = [] + agent_id: Optional[int] = None + + +class OfficialAgentInstallResponse(BaseModel): + """Response payload for POST /repository/agent/official/install.""" + results: List[OfficialAgentInstallItem] + + RepositoryImportRequirementType = Literal[ "model", "knowledge_base", "mcp", "skill", "tool" ] @@ -1341,6 +1656,8 @@ class RepositoryImportRequirementItem(BaseModel): available: bool reason_code: Optional[str] = None suggested_new_name: Optional[str] = None + resolution_required: bool = False + existing_index_name: Optional[str] = None class RepositoryImportPrecheckResponse(BaseModel): @@ -1356,7 +1673,7 @@ class RepositoryImportPrecheckResponse(BaseModel): class AgentRepositoryListingCreateRequest(BaseModel): """Request body for creating a marketplace listing from an agent version.""" - icon: Optional[str] = Field(None, description="Marketplace card icon (emoji or URL)") + icon_url: Optional[str] = Field(None, description="Repository icon URL") downloads: int = Field(0, ge=0, description="Initial download/copy count for card display") tags: Optional[List[str]] = Field(None, description="Marketplace tags") tool_count: Optional[int] = Field( @@ -1371,11 +1688,12 @@ class AgentRepositoryListingDetailResponse(BaseModel): """Detailed marketplace listing payload for repository detail view.""" agent_repository_id: int agent_id: Optional[int] = None + version_no: Optional[int] = None name: str display_name: Optional[str] = None description: Optional[str] = None author: Optional[str] = None - icon: Optional[str] = None + icon_url: Optional[str] = None status: str version_label: Optional[str] = None downloads: int = 0 @@ -1438,6 +1756,12 @@ class SkillResolution(BaseModel): new_name: Optional[str] = None +class KnowledgeBaseResolution(BaseModel): + """User-selected resolution for an existing official knowledge base.""" + knowledge_name: str + action: Literal["reuse", "create_new"] + + class SkillConflictCheckRequest(BaseModel): """Skill names to check before showing the agent import steps.""" skill_names: List[str] @@ -2264,6 +2588,74 @@ class DeleteMcpServiceRequest(BaseModel): # ============================================================================= +class ReasoningControl(BaseModel): + """One reasoning control exposed by the provider catalog.""" + + type: Literal["toggle", "effort", "budget_tokens"] + values: List[str] = Field(default_factory=list) + min: Optional[int] = Field(default=None, ge=0) + max: Optional[int] = Field(default=None, gt=0) + + @model_validator(mode="after") + def validate_control(self) -> "ReasoningControl": + if self.type == "effort" and not self.values: + raise ValueError("Effort reasoning control must declare values") + if self.type == "budget_tokens": + if self.min is None or self.max is None or self.min > self.max: + raise ValueError("Budget-token reasoning control must declare a valid min/max range") + return self + + +class ReasoningCapability(BaseModel): + """Explicit reasoning control capability declared by the model catalog.""" + + status: Literal["supported", "unsupported", "unknown"] = "unknown" + control: Literal["toggle", "effort", "budget_tokens"] = "effort" + levels: List[Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]] = Field(default_factory=list) + default: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = None + wire_format: Literal["reasoning_effort", "thinking_toggle", "thinking_budget"] = "reasoning_effort" + effort_budgets: Dict[str, int] = Field(default_factory=dict) + controls: List[ReasoningControl] = Field(default_factory=list) + provider_id: Optional[str] = None + budget_wire_format: Optional[Literal["thinking_object", "thinking_budget"]] = None + toggle_wire_format: Optional[Literal["thinking_object", "enable_thinking", "chat_template"]] = None + matched_api: Optional[str] = None + matched_model_id: Optional[str] = None + source: Literal["catalog", "models_dev", "operator", "unknown"] = "unknown" + + @model_validator(mode="after") + def validate_levels(self) -> "ReasoningCapability": + if self.status == "supported" and self.control == "effort" and not self.levels: + raise ValueError("Effort reasoning capability must declare at least one level") + if self.default is not None and self.default != "auto" and self.default not in self.levels: + raise ValueError("Reasoning default must be included in reasoning levels") + if any(level not in self.levels for level in self.effort_budgets): + raise ValueError("Reasoning budget keys must be included in reasoning levels") + if any(value < 1024 for value in self.effort_budgets.values()): + raise ValueError("Reasoning budgets must be at least 1024 tokens") + if self.status == "supported" and self.wire_format == "thinking_budget": + missing_budgets = { + level for level in self.levels if level != "none" and level not in self.effort_budgets + } + if missing_budgets: + raise ValueError( + "Thinking-budget reasoning capability must declare a budget for every enabled level" + ) + if self.status != "supported": + self.levels = [] + self.default = None + self.effort_budgets = {} + self.controls = [] + return self + + +# Canonical values accepted by the model-level reasoning default. The +# provider catalog still decides which values are valid for a specific model. +REASONING_EFFORT_VALUES = frozenset( + {"auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"} +) + + class ModelCatalogProfile(BaseModel): """从预置模型目录中读取的单个模型的完整配置描述。 @@ -2286,6 +2678,10 @@ class ModelCatalogProfile(BaseModel): timeout_seconds: Optional[int] = Field(None, gt=0, description="Per-request timeout in seconds") concurrency_limit: Optional[int] = Field(None, gt=0, description="Maximum concurrent requests for this model") capability_profile_version: Optional[str] = Field(None, description="Approved provider/model capability profile version") + reasoning_capability: Optional[ReasoningCapability] = Field( + None, + description="Explicit reasoning control capability for this model", + ) requires_appid: bool = Field(False, description="Whether the model requires model_appid auth (STT/TTS)") requires_access_token: bool = Field(False, description="Whether the model requires access_token auth (STT/TTS)") forced_temperature: Optional[float] = Field( @@ -2416,7 +2812,13 @@ def get_extra_param_keys_for_type(model_type: str) -> List[str]: for the given model type (i.e., fields without a dedicated DB column). """ specs = FIXED_INFERENCE_FIELDS_BY_TYPE.get(model_type, []) - return [s.key for s in specs if s.key not in _FIELDS_WITH_DEDICATED_COLUMN] + keys = [s.key for s in specs if s.key not in _FIELDS_WITH_DEDICATED_COLUMN] + if model_type in {"llm", "chat"}: + # Stored in the existing JSONB column so this feature remains + # backwards-compatible with installations that have no migration. + keys.append("reasoning_effort") + keys.append("reasoning_budget_tokens") + return keys _INVALID_CUSTOM_VALUE = object() @@ -2486,6 +2888,45 @@ def _clean_custom_params(value: Any, logger) -> Optional[Dict[str, Any]]: return clean_custom or None +def _validate_reasoning_extra_param(key: str, value: Any, logger) -> bool: + if key == "enable_thinking": + if isinstance(value, bool): + return True + logger.warning( + "Dropped invalid enable_thinking value %r; expected a boolean", + value, + ) + return False + if key == "reasoning_effort" and value not in REASONING_EFFORT_VALUES: + logger.warning( + "Dropped invalid reasoning_effort value %r; expected one of %s", + value, + sorted(REASONING_EFFORT_VALUES), + ) + return False + if key == "reasoning_budget_tokens": + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return True + logger.warning( + "Dropped invalid reasoning_budget_tokens value %r; expected a positive integer", + value, + ) + return False + return True + + +def _prepare_custom_extra_param( + value: Any, logger +) -> tuple[Optional[Dict[str, Any]], bool]: + if not isinstance(value, dict): + logger.warning( + "__custom__ must be a dict, got %s; dropping", + type(value).__name__, + ) + return None, False + return _clean_custom_params(value, logger), True + + def filter_extra_params(model_type: str, extra_params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """Filter extra_params to only keep keys allowed for the given model type. @@ -2507,21 +2948,17 @@ def filter_extra_params(model_type: str, extra_params: Optional[Dict[str, Any]]) dropped = [] for key, value in extra_params.items(): if key == "__custom__": - if not isinstance(value, dict): - logger.warning( - "__custom__ must be a dict, got %s; dropping", - type(value).__name__, - ) + clean_custom, accepted = _prepare_custom_extra_param(value, logger) + if not accepted: dropped.append(key) continue - clean_custom = _clean_custom_params(value, logger) if clean_custom is not None: filtered["__custom__"] = clean_custom continue - if key in allowed: - filtered[key] = value - else: + if key not in allowed or not _validate_reasoning_extra_param(key, value, logger): dropped.append(key) + continue + filtered[key] = value if dropped: logger.warning( "Dropped extra_params keys not in fixed field set for model_type=%s: %s", diff --git a/backend/database/agent_db.py b/backend/database/agent_db.py index ea0147ec97..55d32ce170 100644 --- a/backend/database/agent_db.py +++ b/backend/database/agent_db.py @@ -1,16 +1,42 @@ import logging from typing import List, Optional -from sqlalchemy import or_, update +from sqlalchemy import or_, text, update from database.client import get_db_session, as_dict, filter_property from database.db_models import AgentInfo, ToolInstance, AgentRelation from database.agent_version_db import query_current_version_no -from consts.const import ASSET_OWNER_TENANT_ID +from consts.const import ASSET_OWNER_TENANT_ID, MAX_AGENTS_PER_TENANT +from consts.exceptions import TenantResourceLimitError from utils.str_utils import convert_list_to_string logger = logging.getLogger("agent_db") +def _enforce_tenant_agent_limit(session, tenant_id: str) -> None: + """Serialize standard-Agent creation and enforce the tenant quota.""" + session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), + {"lock_key": f"tenant-agent-limit:{tenant_id}"}, + ) + agent_count = session.query(AgentInfo.agent_id).filter( + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == 0, + AgentInfo.delete_flag == "N", + or_( + AgentInfo.agent_origin == "USER", + AgentInfo.agent_origin.is_(None), + ), + ).count() + if agent_count >= MAX_AGENTS_PER_TENANT: + raise TenantResourceLimitError( + f"Tenant agent limit reached: maximum {MAX_AGENTS_PER_TENANT} agents per tenant", + resource="agents", + scope="tenant", + limit=MAX_AGENTS_PER_TENANT, + current_count=agent_count, + ) + + def search_agent_info_by_agent_id(agent_id: int, tenant_id: str, version_no: int = 0): """ Search agent info by agent_id. @@ -61,6 +87,49 @@ def search_agent_id_by_agent_name(agent_name: str, tenant_id: str, version_no: i return agent.agent_id +def find_agent_id_by_agent_name(agent_name: str, tenant_id: str, version_no: int = 0): + """Return an Agent ID by name, or ``None`` when no Agent exists. + + This non-raising variant is intended for idempotent provisioning flows + where a missing Agent is an expected branch, not an error condition. + """ + with get_db_session() as session: + agent = session.query(AgentInfo).filter( + AgentInfo.name == agent_name, + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == version_no, + AgentInfo.delete_flag != 'Y').first() + return agent.agent_id if agent else None + + +def search_system_agent( + tenant_id: str, + system_key: str, + version_no: int = 0, +) -> Optional[dict]: + """Return one active platform-owned Agent for the tenant and key.""" + with get_db_session() as session: + agent = session.query(AgentInfo).filter( + AgentInfo.tenant_id == tenant_id, + AgentInfo.system_key == system_key, + AgentInfo.agent_origin == "SYSTEM", + AgentInfo.version_no == version_no, + AgentInfo.delete_flag != "Y", + ).first() + return as_dict(agent) if agent else None + + +def is_system_agent(agent_id: int, tenant_id: str) -> bool: + """Return whether an Agent identity is owned and protected by the platform.""" + with get_db_session() as session: + return session.query(AgentInfo.agent_id).filter( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.agent_origin == "SYSTEM", + AgentInfo.delete_flag != "Y", + ).first() is not None + + def search_blank_sub_agent_by_main_agent_id(tenant_id: str, version_no: int = 0): """ Search blank sub agent by main agent id. @@ -203,6 +272,7 @@ def create_agent(agent_info, tenant_id: str, user_id: str): info_with_metadata.setdefault("context_policy", None) info_with_metadata.setdefault("model_params_override", None) info_with_metadata.setdefault("is_a2a", False) + info_with_metadata.setdefault("enable_protocol_repair_retry", False) info_with_metadata.update({ "tenant_id": tenant_id, "version_no": 0, # Default to draft version @@ -211,6 +281,10 @@ def create_agent(agent_info, tenant_id: str, user_id: str): "is_new": True, # Mark new agents as new }) with get_db_session() as session: + # System Agents are provisioned separately and are excluded by the + # quota query above; ordinary Agent creation is serialized per tenant. + if info_with_metadata.get("agent_origin", "USER") != "SYSTEM": + _enforce_tenant_agent_limit(session, tenant_id) new_agent = AgentInfo(**filter_property(info_with_metadata, AgentInfo)) new_agent.delete_flag = 'N' session.add(new_agent) @@ -221,6 +295,9 @@ def create_agent(agent_info, tenant_id: str, user_id: str): "agent_id": new_agent.agent_id, "tenant_id": new_agent.tenant_id, "name": new_agent.name, + "system_key": getattr(new_agent, "system_key", None), + "agent_origin": getattr(new_agent, "agent_origin", "USER"), + "system_revision": getattr(new_agent, "system_revision", None), "display_name": new_agent.display_name, "description": new_agent.description, "author": new_agent.author, @@ -233,6 +310,7 @@ def create_agent(agent_info, tenant_id: str, user_id: str): "is_main_agent": new_agent.is_main_agent, "provide_run_summary": new_agent.provide_run_summary, "allow_chat_metadata": bool(new_agent.allow_chat_metadata), + "enable_protocol_repair_retry": new_agent.enable_protocol_repair_retry, "business_description": new_agent.business_description, "business_logic_model_id": new_agent.business_logic_model_id, "business_logic_model_name": new_agent.business_logic_model_name, @@ -257,7 +335,35 @@ def create_agent(agent_info, tenant_id: str, user_id: str): return result -def update_agent(agent_id, agent_info, user_id, version_no: int = 0): +def update_system_agent_revision( + *, + agent_id: int, + tenant_id: str, + system_revision: str, + user_id: str, +) -> None: + """Record the applied release revision on the protected system draft.""" + with get_db_session() as session: + agent = session.query(AgentInfo).filter( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == 0, + AgentInfo.agent_origin == "SYSTEM", + AgentInfo.delete_flag != "Y", + ).first() + if not agent: + raise ValueError("System Agent draft not found") + agent.system_revision = system_revision + agent.updated_by = user_id + + +def update_agent( + agent_id, + agent_info, + user_id, + version_no: int = 0, + allow_system: bool = False, +): """ Update an existing agent in the database. Default version_no=0 updates the draft version. @@ -280,6 +386,8 @@ def update_agent(agent_id, agent_info, user_id, version_no: int = 0): ).first() if not agent: raise ValueError("ag_tenant_agent_t Agent not found") + if getattr(agent, "agent_origin", "USER") == "SYSTEM" and not allow_system: + raise ValueError("System Agent is managed by the platform") agent_data = dict(agent_info.__dict__) fields_set = getattr(agent_info, "model_fields_set", None) @@ -311,6 +419,24 @@ def update_agent_icon(agent_id: int, tenant_id: str, icon_url: str, user_id: str raise ValueError("ag_tenant_agent_t Agent not found") +def update_agent_display_name( + agent_id: int, tenant_id: str, display_name: str, user_id: str +) -> None: + """Update the display name on every active version of an agent.""" + with get_db_session() as session: + result = session.execute( + update(AgentInfo) + .where( + AgentInfo.agent_id == agent_id, + AgentInfo.tenant_id == tenant_id, + AgentInfo.delete_flag == "N", + ) + .values(display_name=display_name, updated_by=user_id) + ) + if result.rowcount == 0: + raise ValueError("ag_tenant_agent_t Agent not found") + + def query_agent_records_for_nl2agent(agent_id: int, tenant_id: str) -> list[dict]: """Return all tenant-owned records for NL2Agent draft validation. @@ -398,6 +524,57 @@ def query_all_agent_info_by_tenant_id(tenant_id: str, version_no: int = 0): return [as_dict(agent) for agent in agents] +def query_agent_list_candidates_by_tenant_id( + tenant_id: str, *, include_description: bool = False +) -> list[dict]: + """Load only fields needed to filter and page visible draft agents.""" + columns = [ + AgentInfo.agent_id, + AgentInfo.tenant_id, + AgentInfo.name, + AgentInfo.display_name, + AgentInfo.created_by, + AgentInfo.create_time, + AgentInfo.group_ids, + AgentInfo.ingroup_permission, + ] + if include_description: + columns.append(AgentInfo.description) + with get_db_session() as session: + rows = ( + session.query(*columns) + .filter( + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == 0, + AgentInfo.delete_flag != 'Y', + AgentInfo.enabled.is_(True), + or_(AgentInfo.agent_origin.is_(None), AgentInfo.agent_origin != "SYSTEM"), + or_(AgentInfo.system_key.is_(None), AgentInfo.system_key == ""), + ) + .order_by(AgentInfo.create_time.desc(), AgentInfo.agent_id.desc()) + .all() + ) + return [dict(row._mapping) for row in rows] + + +def query_agent_info_by_ids(tenant_id: str, agent_ids: list[int]) -> list[dict]: + """Load complete draft records for one already-authorized agent page.""" + if not agent_ids: + return [] + with get_db_session() as session: + agents = ( + session.query(AgentInfo) + .filter( + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == 0, + AgentInfo.delete_flag != 'Y', + AgentInfo.agent_id.in_(agent_ids), + ) + .all() + ) + return [as_dict(agent) for agent in agents] + + def batch_search_agent_display_names(agent_ids: List[int], tenant_id: str) -> dict: """ Batch query agent display names by agent IDs. @@ -423,7 +600,25 @@ def batch_search_agent_display_names(agent_ids: List[int], tenant_id: str) -> di return {a.agent_id: (a.display_name or a.name) for a in agents} -def insert_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: str, user_id: str, version_no: int = 0, selected_agent_version_no: Optional[int] = None) -> bool: +def _ensure_system_relation_mutation_allowed( + parent_agent_id: int, + tenant_id: str, + allow_system: bool, +) -> None: + """Reject ordinary relation writes targeting a platform Agent.""" + if not allow_system and is_system_agent(parent_agent_id, tenant_id) is True: + raise ValueError("System Agent is managed by the platform") + + +def insert_related_agent( + parent_agent_id: int, + child_agent_id: int, + tenant_id: str, + user_id: str, + version_no: int = 0, + selected_agent_version_no: Optional[int] = None, + allow_system: bool = False, +) -> bool: """ Insert a related agent. Default version_no=0 creates the draft version. @@ -436,6 +631,9 @@ def insert_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: s version_no: Parent agent version number. Default 0 = draft/editing state selected_agent_version_no: Pinned version of child agent. None = runtime fallback to child current_version_no """ + _ensure_system_relation_mutation_allowed( + parent_agent_id, tenant_id, allow_system + ) try: relation_info = { "parent_agent_id": parent_agent_id, @@ -457,7 +655,14 @@ def insert_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: s return False -def delete_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: str, user_id: str, version_no: int = 0) -> bool: +def delete_related_agent( + parent_agent_id: int, + child_agent_id: int, + tenant_id: str, + user_id: str, + version_no: int = 0, + allow_system: bool = False, +) -> bool: """ Delete a related agent. Default version_no=0 deletes the draft version. @@ -469,6 +674,9 @@ def delete_related_agent(parent_agent_id: int, child_agent_id: int, tenant_id: s user_id: User ID version_no: Version number to filter. Default 0 = draft/editing state """ + _ensure_system_relation_mutation_allowed( + parent_agent_id, tenant_id, allow_system + ) try: with get_db_session() as session: session.query(AgentRelation).filter( @@ -537,6 +745,7 @@ def update_related_agents( user_id: str, related_agents: Optional[List[dict]] = None, version_no: int = 0, + allow_system: bool = False, ): """ Update related agents for a parent agent by replacing all existing relations. @@ -552,6 +761,9 @@ def update_related_agents( related_agents: List of dicts with 'agent_id' and optional 'version_no' keys version_no: Version number to filter. Default 0 = draft/editing state """ + _ensure_system_relation_mutation_allowed( + parent_agent_id, tenant_id, allow_system + ) new_related_ids, version_map = _parse_related_agents(related_agents) with get_db_session() as session: @@ -586,7 +798,13 @@ def update_related_agents( _update_existing_relations(current_relations, ids_to_update, version_map, user_id) -def delete_agent_relationship(agent_id: int, tenant_id: str, user_id: str, version_no: int = 0): +def delete_agent_relationship( + agent_id: int, + tenant_id: str, + user_id: str, + version_no: int = 0, + allow_system: bool = False, +): """ Delete all relationships for an agent. Default version_no=0 deletes the draft version. @@ -597,6 +815,7 @@ def delete_agent_relationship(agent_id: int, tenant_id: str, user_id: str, versi user_id: User ID version_no: Version number to filter. Default 0 = draft/editing state """ + _ensure_system_relation_mutation_allowed(agent_id, tenant_id, allow_system) with get_db_session() as session: session.query(AgentRelation).filter( AgentRelation.parent_agent_id == agent_id, diff --git a/backend/database/agent_evaluation_db.py b/backend/database/agent_evaluation_db.py index 858dc8e271..a2a7844951 100644 --- a/backend/database/agent_evaluation_db.py +++ b/backend/database/agent_evaluation_db.py @@ -213,19 +213,21 @@ def get_agent_evaluation(agent_evaluation_id: int, tenant_id: str) -> dict[str, def list_agent_evaluations_by_agent( - agent_id: int, + agent_ids: list[int], tenant_id: str, limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: - """Return evaluation runs for an agent, most-recent first. + """Return tenant evaluation runs, optionally filtered by agent IDs. - ``limit == 0`` means "return all rows" (the caller has already narrowed - the query to a single agent, so the window is bounded by the tenant's - per-agent run count); otherwise ``limit``/``offset`` are applied as a - normal pagination window. + ``limit == 0`` means "return all rows"; otherwise ``limit``/``offset`` + are applied as a normal pagination window. """ with get_db_session() as session: + filters = [AgentEvaluation.tenant_id == tenant_id] + if agent_ids: + filters.append(AgentEvaluation.agent_id.in_(agent_ids)) + q = ( session.query( AgentEvaluation, @@ -242,10 +244,7 @@ def list_agent_evaluations_by_agent( (AgentEvaluation.judge_model_id == ModelRecord.model_id) & (AgentEvaluation.tenant_id == ModelRecord.tenant_id), ) - .filter( - AgentEvaluation.tenant_id == tenant_id, - AgentEvaluation.agent_id == agent_id, - ) + .filter(*filters) .order_by(AgentEvaluation.create_time.desc()) ) if limit > 0: diff --git a/backend/database/agent_repository_db.py b/backend/database/agent_repository_db.py index f5fb71b072..e8da664569 100644 --- a/backend/database/agent_repository_db.py +++ b/backend/database/agent_repository_db.py @@ -31,7 +31,7 @@ "tags", "tool_count", "version_name", - "icon", + "icon_url", "downloads", "agent_info_json", }) @@ -184,7 +184,7 @@ def list_agent_repository_summaries( AgentRepository.tags, AgentRepository.tool_count, AgentRepository.version_name, - AgentRepository.icon, + AgentRepository.icon_url, AgentRepository.downloads, AgentRepository.content, ).filter( @@ -209,7 +209,7 @@ def list_agent_repository_summaries( "tags": row.tags, "tool_count": row.tool_count, "version_name": row.version_name, - "icon": row.icon, + "icon_url": row.icon_url, "downloads": row.downloads, "content": row.content, } @@ -227,13 +227,14 @@ def update_agent_repository_by_id( """Update a repository listing owned by the publisher tenant. Returns affected row count.""" allowed_fields = { "display_name", + "name", "description", "author", "submitted_by", "tags", "tool_count", "version_name", - "icon", + "icon_url", "downloads", "version_no", "agent_info_json", @@ -353,6 +354,7 @@ def list_agent_repository_by_agent_ids( *, statuses: Collection[str], publisher_tenant_id: str, + publisher_user_id: Optional[str] = None, ) -> List[dict]: """List repository rows for the given agents, scoped to publisher tenant and statuses.""" if not agent_ids: @@ -360,7 +362,7 @@ def list_agent_repository_by_agent_ids( status_list = list(statuses) with get_db_session() as session: - rows = ( + query = ( session.query( AgentRepository.agent_repository_id, AgentRepository.agent_id, @@ -376,7 +378,11 @@ def list_agent_repository_by_agent_ids( AgentRepository.agent_id.in_(agent_ids), AgentRepository.status.in_(status_list), ) - .order_by( + ) + if publisher_user_id is not None: + query = query.filter(AgentRepository.publisher_user_id == publisher_user_id) + rows = ( + query.order_by( AgentRepository.agent_id, AgentRepository.create_time.desc(), ) @@ -411,6 +417,26 @@ def increment_agent_repository_downloads(agent_repository_id: int) -> int: return int(result.rowcount or 0) +def soft_delete_agent_repository_record( + repository_id: int, + *, + publisher_tenant_id: str, + user_id: str, +) -> int: + """Soft-delete one repository listing scoped to its publisher tenant.""" + with get_db_session() as session: + result = session.execute( + update(AgentRepository) + .where( + AgentRepository.agent_repository_id == repository_id, + AgentRepository.publisher_tenant_id == publisher_tenant_id, + AgentRepository.delete_flag != "Y", + ) + .values(delete_flag="Y", updated_by=user_id) + ) + return int(result.rowcount or 0) + + def sum_agent_repository_downloads_by_agent_ids( agent_ids: List[int], ) -> Dict[int, int]: @@ -486,4 +512,3 @@ def fetch_draft_agent_mine_metadata( } for row in rows } - diff --git a/backend/database/agent_version_db.py b/backend/database/agent_version_db.py index 391e5d5e8a..3db9fc7f62 100644 --- a/backend/database/agent_version_db.py +++ b/backend/database/agent_version_db.py @@ -29,6 +29,7 @@ def search_version_by_version_no( with get_db_session() as session: version = session.query(AgentVersion).filter( AgentVersion.agent_id == agent_id, + AgentVersion.tenant_id == tenant_id, AgentVersion.version_no == version_no, AgentVersion.delete_flag == 'N', ).first() @@ -43,7 +44,7 @@ def batch_search_version_names( """ Batch query version names for multiple (agent_id, version_no) pairs. - Returns list of dicts: [{"agent_id": int, "version_no": int, "version_name": Optional[str]}] + Returns version names and creation times for the requested agent versions. """ if not agent_ids or not version_nos: return [] @@ -61,6 +62,7 @@ def batch_search_version_names( "agent_id": v.agent_id, "version_no": v.version_no, "version_name": v.version_name, + "create_time": v.create_time, }) return result @@ -635,4 +637,4 @@ def insert_skill_snapshot( Insert skill instance snapshot. """ with get_db_session() as session: - session.execute(insert(SkillInstance).values(**skill_data)) \ No newline at end of file + session.execute(insert(SkillInstance).values(**skill_data)) diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index 197578ec65..0be5292719 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -3,11 +3,20 @@ from datetime import datetime from typing import Any, Dict, List, Optional, TypedDict -from sqlalchemy import asc, desc, func, insert, select, update +from sqlalchemy import asc, desc, func, insert, select, text, update +from consts.const import ( + MAX_CONVERSATION_TURNS, + MAX_CONVERSATIONS_PER_USER, + MESSAGE_ROLE, +) +from consts.error_code import ErrorCode from consts.exceptions import ( + AppException, ConversationNotFoundError, RuntimeMetadataVersionConflict, + WorkbenchConfigVersionConflict, + WorkbenchError, ) from .client import as_dict, db_client, get_db_session @@ -38,6 +47,12 @@ def _serialize_unit_content(content: Any) -> str: return json.dumps(content, ensure_ascii=False) +def _optional_conversation_column(name: str, fallback: Any): + """Keep rolling-schema and unit-test stubs readable during migration.""" + column = getattr(ConversationRecord, name, None) + return column if column is not None else fallback + + class SearchRecord(TypedDict): message_id: int source_type: str @@ -66,6 +81,8 @@ class ConversationHistory(TypedDict): knowledge_scope: Optional[Dict[str, Any]] runtime_metadata: Dict[str, Any] runtime_metadata_version: int + workbench_config: Optional[Dict[str, Any]] + workbench_config_version: int create_time: int message_records: List[MessageRecord] search_records: List[SearchRecord] @@ -113,11 +130,76 @@ def _get_effective_tenant_id(user_tenant: Dict[str, Any]) -> str: return DEFAULT_TENANT_ID +def _lock_conversation_limit_scope(session, scope: str) -> None: + """Serialize quota checks for one user or conversation within a transaction.""" + session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), + {"lock_key": scope}, + ) + + +def _enforce_user_conversation_limit(session, user_id: Optional[str]) -> None: + """Reject creation when a user already owns the configured number of conversations.""" + if not user_id: + return + + _lock_conversation_limit_scope(session, f"conversation-user-limit:{user_id}") + statement = select(func.count(ConversationRecord.conversation_id)).where( + ConversationRecord.created_by == user_id, + ConversationRecord.delete_flag == "N", + ) + conversation_count = int(session.scalar(statement) or 0) + if conversation_count >= MAX_CONVERSATIONS_PER_USER: + raise AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + "Conversation history limit reached: " + f"maximum {MAX_CONVERSATIONS_PER_USER} conversations per user", + details={ + "resource": "conversations", + "scope": "user", + "limit": MAX_CONVERSATIONS_PER_USER, + "current_count": conversation_count, + }, + ) + + +def _enforce_conversation_turn_limit( + session, + conversation_id: int, + message_role: str, + user_id: Optional[str], +) -> None: + """Reject a new user message when the conversation turn quota is reached.""" + if not user_id or message_role != MESSAGE_ROLE["USER"]: + return + + _lock_conversation_limit_scope(session, f"conversation-turn-limit:{conversation_id}") + statement = select(func.count(ConversationMessage.message_id)).where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.message_role == MESSAGE_ROLE["USER"], + ConversationMessage.delete_flag == "N", + ) + turn_count = int(session.scalar(statement) or 0) + if turn_count >= MAX_CONVERSATION_TURNS: + raise AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + "Conversation turn limit reached: " + f"maximum {MAX_CONVERSATION_TURNS} turns per conversation", + details={ + "resource": "conversation_turns", + "scope": "conversation", + "limit": MAX_CONVERSATION_TURNS, + "current_count": turn_count, + }, + ) + + def create_conversation(conversation_title: str, user_id: Optional[str] = None, agent_id: Optional[int] = None, chat_mode: Optional[str] = None, knowledge_scope: Optional[Dict[str, Any]] = None, - runtime_metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + runtime_metadata: Optional[Dict[str, Any]] = None, + workbench_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ Create a new conversation record @@ -132,6 +214,8 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None, Dict[str, Any]: Dictionary containing complete information of the newly created conversation """ with get_db_session() as session: + _enforce_user_conversation_limit(session, user_id) + # Prepare data dictionary data = {"conversation_title": conversation_title, "delete_flag": 'N'} if agent_id is not None: @@ -143,6 +227,9 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None, if runtime_metadata is not None: data["runtime_metadata"] = deepcopy(runtime_metadata) data["runtime_metadata_version"] = 1 + if workbench_config is not None: + data["workbench_config"] = deepcopy(workbench_config) + data["workbench_config_version"] = 1 if user_id: data = add_creation_tracking(data, user_id) @@ -154,6 +241,8 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None, ConversationRecord.knowledge_scope, ConversationRecord.runtime_metadata, ConversationRecord.runtime_metadata_version, + _optional_conversation_column("workbench_config", None), + _optional_conversation_column("workbench_config_version", 0), (func.extract('epoch', ConversationRecord.create_time) * 1000).label('create_time'), (func.extract('epoch', ConversationRecord.update_time) @@ -171,6 +260,16 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None, "knowledge_scope": record.knowledge_scope, "runtime_metadata": record.runtime_metadata or {}, "runtime_metadata_version": record.runtime_metadata_version or 0, + "workbench_config": ( + record.workbench_config + if isinstance(getattr(record, "workbench_config", None), dict) + else None + ), + "workbench_config_version": ( + record.workbench_config_version + if isinstance(getattr(record, "workbench_config_version", None), int) + else 0 + ), "create_time": int(record.create_time), "update_time": int(record.update_time) } @@ -199,6 +298,13 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ # Ensure conversation_id is integer type conversation_id = int(message_data['conversation_id']) message_idx = int(message_data['message_idx']) + message_role = message_data['role'] + _enforce_conversation_turn_limit( + session=session, + conversation_id=conversation_id, + message_role=message_role, + user_id=user_id, + ) minio_files = message_data.get('minio_files') # Convert minio_files to JSON string for storage @@ -208,7 +314,7 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ minio_files = json.dumps(minio_files) # Prepare data dictionary - data = {"conversation_id": conversation_id, "message_index": message_idx, "message_role": message_data['role'], + data = {"conversation_id": conversation_id, "message_index": message_idx, "message_role": message_role, "message_content": message_data['content'], "minio_files": minio_files, "opinion_flag": None, "delete_flag": 'N', "status": status} if user_id: @@ -725,6 +831,131 @@ def resolve_conversation_runtime_metadata( } +def _assert_workbench_topology(record, config: Dict[str, Any]) -> None: + """Keep entrypoint and creation workflows stable while allowing chat mounts to change.""" + current = record.workbench_config + if not isinstance(current, dict) or current.get("schema_version") != 3: + raise WorkbenchError("WORKBENCH_CONVERSATION_REQUIRED", status_code=409) + creation_modes = {"skill_create", "agent_create"} + if current.get("mode") in creation_modes or config.get("mode") in creation_modes: + if current.get("mode") != config.get("mode"): + raise WorkbenchError("WORKBENCH_TOPOLOGY_LOCKED") + + +def replace_conversation_workbench_config( + conversation_id: int, + user_id: str, + config: Dict[str, Any], + expected_version: int, + only_if_changed: bool = False, +) -> Dict[str, Any]: + """Atomically replace canonical Workbench config and compatibility projections.""" + + with get_db_session() as session: + stmt = ( + select(ConversationRecord) + .where( + ConversationRecord.conversation_id == int(conversation_id), + ConversationRecord.created_by == user_id, + ConversationRecord.delete_flag == 'N', + ) + .with_for_update() + ) + record = session.scalars(stmt).first() + if record is None: + raise ConversationNotFoundError("Conversation not found") + + current_version = int(record.workbench_config_version or 0) + if expected_version != current_version: + raise WorkbenchConfigVersionConflict( + current_version, + deepcopy(record.workbench_config), + ) + + _assert_workbench_topology(record, config) + normalized = deepcopy(config) + if only_if_changed and normalized == record.workbench_config: + return { + "workbench_config": deepcopy(record.workbench_config), + "workbench_config_version": current_version, + "agent_id": record.agent_id, + "knowledge_scope": deepcopy(record.knowledge_scope), + } + projected_scope = deepcopy(normalized.get("knowledge_scope")) + + record.workbench_config = normalized + record.workbench_config_version = current_version + 1 + record.knowledge_scope = projected_scope + record.updated_by = user_id + record.update_time = func.current_timestamp() + session.flush() + + return { + "workbench_config": deepcopy(record.workbench_config), + "workbench_config_version": int(record.workbench_config_version), + "agent_id": record.agent_id, + "knowledge_scope": deepcopy(record.knowledge_scope), + } + + +def replace_conversation_workbench_and_metadata( + conversation_id: int, + user_id: str, + config: Dict[str, Any], + expected_config_version: int, + metadata: Dict[str, Any], + expected_metadata_version: Optional[int], +) -> Dict[str, Any]: + """Preflight and replace both independent runtime declarations atomically.""" + + with get_db_session() as session: + stmt = ( + select(ConversationRecord) + .where( + ConversationRecord.conversation_id == int(conversation_id), + ConversationRecord.created_by == user_id, + ConversationRecord.delete_flag == 'N', + ) + .with_for_update() + ) + record = session.scalars(stmt).first() + if record is None: + raise ConversationNotFoundError("Conversation not found") + + current_config_version = int(record.workbench_config_version or 0) + current_metadata_version = int(record.runtime_metadata_version or 0) + if expected_config_version != current_config_version: + raise WorkbenchConfigVersionConflict( + current_config_version, + deepcopy(record.workbench_config), + ) + if ( + expected_metadata_version is not None + and expected_metadata_version != current_metadata_version + ): + raise RuntimeMetadataVersionConflict(current_metadata_version) + + _assert_workbench_topology(record, config) + normalized = deepcopy(config) + config_changed = normalized != record.workbench_config + record.workbench_config = normalized + record.workbench_config_version = current_config_version + int(config_changed) + record.knowledge_scope = deepcopy(normalized.get("knowledge_scope")) + record.runtime_metadata = deepcopy(metadata) + record.runtime_metadata_version = current_metadata_version + 1 + record.updated_by = user_id + record.update_time = func.current_timestamp() + session.flush() + return { + "workbench_config": deepcopy(record.workbench_config), + "workbench_config_version": int(record.workbench_config_version), + "agent_id": record.agent_id, + "knowledge_scope": deepcopy(record.knowledge_scope), + "runtime_metadata": deepcopy(record.runtime_metadata), + "runtime_metadata_version": int(record.runtime_metadata_version), + } + + def get_conversation_messages(conversation_id: int) -> List[Dict[str, Any]]: """ Get all messages in a conversation @@ -880,8 +1111,11 @@ def get_conversation_list_page( week_start_ms: int, limit: Optional[int] = None, offset: int = 0, + conversation_type: Optional[str] = None, ) -> Dict[str, Any]: - """Return one conversation page and its bucket counts in one query.""" + """Return a conversation page and bucket counts, optionally scoped by origin.""" + if conversation_type not in (None, "agent_chat", "workbench"): + raise ValueError("Invalid conversation type") with get_db_session() as session: created_ms = func.extract('epoch', ConversationRecord.create_time) * 1000 stmt = select( @@ -905,6 +1139,10 @@ def get_conversation_list_page( desc(ConversationRecord.create_time), desc(ConversationRecord.conversation_id), ) + if conversation_type == "workbench": + stmt = stmt.where(ConversationRecord.workbench_config.is_not(None)) + elif conversation_type == "agent_chat": + stmt = stmt.where(ConversationRecord.workbench_config.is_(None)) if limit is not None: stmt = stmt.limit(limit) if offset: @@ -960,6 +1198,24 @@ def update_conversation_agent_id(conversation_id: int, agent_id: int, user_id: O return result.rowcount > 0 +def rebind_conversation_agent_id( + conversation_id: int, expected_agent_id: int, agent_id: int, user_id: str +) -> bool: + """Rebind an owned creation conversation only if its Agent has not changed.""" + with get_db_session() as session: + result = session.execute( + update(ConversationRecord) + .where( + ConversationRecord.conversation_id == conversation_id, + ConversationRecord.created_by == user_id, + ConversationRecord.agent_id == expected_agent_id, + ConversationRecord.delete_flag == 'N', + ) + .values(agent_id=agent_id, update_time=func.current_timestamp(), updated_by=user_id) + ) + return result.rowcount == 1 + + # Allowed values for conversation_record_t.chat_mode. Anything outside this set # is rejected at the service boundary so the column never stores free-form text. CHAT_MODE_VALUES = {"planning", "execution"} @@ -1337,6 +1593,8 @@ def get_conversation_history(conversation_id: int, user_id: Optional[str] = None ConversationRecord.knowledge_scope, ConversationRecord.runtime_metadata, ConversationRecord.runtime_metadata_version, + _optional_conversation_column("workbench_config", None), + _optional_conversation_column("workbench_config_version", 0), (func.extract('epoch', ConversationRecord.create_time) * 1000).label('create_time') ).where( @@ -1444,6 +1702,8 @@ def get_conversation_history(conversation_id: int, user_id: Optional[str] = None 'knowledge_scope': conversation.get('knowledge_scope'), 'runtime_metadata': conversation.get('runtime_metadata') or {}, 'runtime_metadata_version': int(conversation.get('runtime_metadata_version') or 0), + 'workbench_config': conversation.get('workbench_config'), + 'workbench_config_version': int(conversation.get('workbench_config_version') or 0), 'create_time': int(conversation['create_time']), 'message_records': message_list, 'search_records': [as_dict(record) for record in search_records], diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 3c882d2347..314e31d4ca 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -90,6 +90,17 @@ class ConversationRecord(TableBase): server_default=text("0"), doc="Monotonic version of conversation runtime metadata", ) + workbench_config = Column( + JSONB, + nullable=True, + doc="Canonical schema-v3 Workbench conversation declaration", + ) + workbench_config_version = Column( + Integer, + nullable=False, + server_default=text("0"), + doc="Monotonic version of the canonical Workbench declaration", + ) class ConversationMessage(TableBase): @@ -663,6 +674,21 @@ class AgentInfo(TableBase): version_no = Column(Integer, default=0, nullable=False, primary_key=True, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") name = Column(String(100), doc="Agent name") + system_key = Column( + String(100), + doc="Stable platform-owned Agent key; NULL for user-created Agents", + ) + agent_origin = Column( + String(20), + default="USER", + nullable=False, + server_default=text("'USER'"), + doc="Agent ownership origin: USER or SYSTEM", + ) + system_revision = Column( + String(100), + doc="Server-controlled Nexent release revision for a system Agent", + ) display_name = Column(String(100), doc="Agent display name") description = Column(Text, doc="Description") author = Column(String(100), doc="Agent author") @@ -700,6 +726,13 @@ class AgentInfo(TableBase): ), ) enable_context_manager = Column(Boolean, default=True, doc="Whether to enable context management (compression) for this agent") + enable_protocol_repair_retry = Column( + Boolean, + default=False, + server_default=text("false"), + nullable=False, + comment="Whether this agent uses strict output validation and silent protocol repair", + ) is_a2a = Column(Boolean, default=False, nullable=False, doc="Whether to publish this agent as an A2A Server agent") verification_config = Column(JSONB, doc="Layered ReAct self-verification configuration") context_policy = Column(JSONB, doc="Agent-level context processing policy override") @@ -1710,7 +1743,7 @@ class AgentRepository(TableBase): tags = Column(ARRAY(Text), doc="Marketplace tags") tool_count = Column(Integer, doc="Total tool count across all agents in the bundle (display only)") - icon = Column(String(100), doc="Marketplace card icon (emoji or URL)") + icon_url = Column(String(1024), doc="Repository icon URL") downloads = Column(Integer, default=0, doc="Marketplace download/copy count for card display") version_name = Column(String(100), diff --git a/backend/database/group_db.py b/backend/database/group_db.py index 4a2117dce6..f91155c36c 100644 --- a/backend/database/group_db.py +++ b/backend/database/group_db.py @@ -130,9 +130,14 @@ def add_group(tenant_id: str, group_name: str, group_description: Optional[str] ).count() group_count = group_count if isinstance(group_count, int) else 0 if group_count >= _GROUP_LIMIT: - raise TenantResourceLimitError( + error = TenantResourceLimitError( f"Tenant group limit reached: maximum {_GROUP_LIMIT} groups per tenant" ) + error.resource = "groups" + error.scope = "tenant" + error.limit = _GROUP_LIMIT + error.current_count = group_count + raise error group = TenantGroupInfo( tenant_id=tenant_id, group_name=group_name, diff --git a/backend/database/knowledge_db.py b/backend/database/knowledge_db.py index 259d0635ec..32dd40a57e 100644 --- a/backend/database/knowledge_db.py +++ b/backend/database/knowledge_db.py @@ -5,6 +5,13 @@ from sqlalchemy import func, text from sqlalchemy.exc import SQLAlchemyError +from consts.const import ( + MAX_KNOWLEDGE_BASES_PER_TENANT, + MAX_KNOWLEDGE_BASES_PER_USER, + MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER, +) +from consts.error_code import ErrorCode +from consts.exceptions import AppException from consts.exceptions import DuplicateError from consts.scheduler import VALID_SUMMARY_FREQUENCIES from database.client import as_dict, get_db_session @@ -13,6 +20,135 @@ logger = logging.getLogger("knowledge_db") +_PRIVILEGED_KNOWLEDGE_ROLES = { + "DEV", + "ADMIN", + "SU", + "SUPER_ADMIN", + "ASSET_OWNER", +} + + +def _count_or_zero(value: Any) -> int: + """Keep lightweight database doubles harmless while preserving real counts.""" + return value if isinstance(value, int) else 0 + + +def _get_user_role_in_session(session, user_id: Optional[str], tenant_id: Optional[str]) -> str: + """Resolve the creator role inside the same transaction as the quota check.""" + if not user_id or not tenant_id: + return "USER" + try: + from database.db_models import UserTenant + + role = session.query(UserTenant.user_role).filter( + UserTenant.user_id == user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.delete_flag == "N", + ).scalar() + except Exception: + logger.exception("Failed to resolve knowledge-base creator role") + return "USER" + return role.upper() if isinstance(role, str) and role else "USER" + + +def _raise_knowledge_limit_error(scope: str, limit: int, current_count: int, role: str = None) -> None: + """Raise a structured error that preserves the applicable limit for the UI.""" + subject = "tenant" if scope == "tenant" else "user" + details = { + "resource": "knowledge_bases", + "scope": scope, + "limit": limit, + "current_count": current_count, + } + if role: + details["role"] = role + raise AppException( + ErrorCode.KNOWLEDGE_RESOURCE_EXCEEDED, + f"Knowledge base {subject} limit reached: maximum {limit} knowledge bases per {subject}", + details=details, + ) + + +def _enforce_knowledge_limits(session, tenant_id: Optional[str], user_id: Optional[str]) -> None: + """Enforce tenant and creator knowledge-base limits before inserting a record.""" + if not tenant_id: + return + + session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), + {"lock_key": f"knowledge-tenant-limit:{tenant_id}"}, + ) + tenant_count = _count_or_zero(session.query(KnowledgeRecord).filter( + KnowledgeRecord.tenant_id == tenant_id, + KnowledgeRecord.delete_flag != "Y", + ).count()) + if tenant_count >= MAX_KNOWLEDGE_BASES_PER_TENANT: + _raise_knowledge_limit_error("tenant", MAX_KNOWLEDGE_BASES_PER_TENANT, tenant_count) + + if not user_id: + return + role = _get_user_role_in_session(session, user_id, tenant_id) + user_limit = ( + MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER + if role in _PRIVILEGED_KNOWLEDGE_ROLES + else MAX_KNOWLEDGE_BASES_PER_USER + ) + session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), + {"lock_key": f"knowledge-user-limit:{tenant_id}:{user_id}"}, + ) + user_count = _count_or_zero(session.query(KnowledgeRecord).filter( + KnowledgeRecord.tenant_id == tenant_id, + KnowledgeRecord.created_by == user_id, + KnowledgeRecord.delete_flag != "Y", + ).count()) + if user_count >= user_limit: + _raise_knowledge_limit_error("user", user_limit, user_count, role) + + +def _create_knowledge_record_in_session(session, query: Dict[str, Any]) -> Dict[str, Any]: + """Create a knowledge record using an existing transaction.""" + tenant_id = query.get("tenant_id") + user_id = query.get("user_id") + _enforce_knowledge_limits(session, tenant_id, user_id) + + raw_knowledge_name = query.get("knowledge_name") or query.get("index_name") + knowledge_name = raw_knowledge_name.strip() if isinstance(raw_knowledge_name, str) else raw_knowledge_name + _lock_and_check_knowledge_name(session, tenant_id, knowledge_name) + group_ids = query.get("group_ids") + data: Dict[str, Any] = { + "knowledge_describe": query.get("knowledge_describe", ""), + "created_by": user_id, + "updated_by": user_id, + "knowledge_sources": query.get("knowledge_sources", "elasticsearch"), + "tenant_id": tenant_id, + "embedding_model_name": query.get("embedding_model_name"), + "embedding_model_id": query.get("embedding_model_id"), + "knowledge_name": knowledge_name, + "group_ids": convert_list_to_string(group_ids) if isinstance(group_ids, list) else group_ids, + "ingroup_permission": query.get("ingroup_permission"), + "preserve_source_file": query.get("preserve_source_file", True), + } + if "quota_limit_bytes" in query: + data["quota_limit_bytes"] = query["quota_limit_bytes"] + explicit_index_name = query.get("index_name") + if explicit_index_name: + data["index_name"] = explicit_index_name + + new_record = KnowledgeRecord(**data) + session.add(new_record) + session.flush() + if not explicit_index_name: + new_record.index_name = _generate_index_name(new_record.knowledge_id) + session.flush() + session.commit() + return { + "knowledge_id": new_record.knowledge_id, + "index_name": new_record.index_name, + "knowledge_name": new_record.knowledge_name, + } + def _lock_and_check_knowledge_name(session, tenant_id: Optional[str], knowledge_name: Optional[str]) -> None: """Serialize and validate tenant-scoped knowledge base name creation.""" @@ -61,56 +197,7 @@ def create_knowledge_record(query: Dict[str, Any]) -> Dict[str, Any]: """ try: with get_db_session() as session: - # Determine user-facing knowledge base name - raw_knowledge_name = query.get("knowledge_name") or query.get("index_name") - knowledge_name = raw_knowledge_name.strip() if isinstance(raw_knowledge_name, str) else raw_knowledge_name - tenant_id = query.get("tenant_id") - _lock_and_check_knowledge_name(session, tenant_id, knowledge_name) - - # Prepare data dictionary - group_ids = query.get("group_ids") - data: Dict[str, Any] = { - "knowledge_describe": query.get("knowledge_describe", ""), - "created_by": query.get("user_id"), - "updated_by": query.get("user_id"), - "knowledge_sources": query.get("knowledge_sources", "elasticsearch"), - "tenant_id": tenant_id, - "embedding_model_name": query.get("embedding_model_name"), - "embedding_model_id": query.get("embedding_model_id"), - "knowledge_name": knowledge_name, - "group_ids": convert_list_to_string(group_ids) if isinstance(group_ids, list) else group_ids, - "ingroup_permission": query.get("ingroup_permission"), - "preserve_source_file": query.get("preserve_source_file", True), - } - - # Per-KB soft quota (optional, null = unlimited) - if "quota_limit_bytes" in query: - data["quota_limit_bytes"] = query["quota_limit_bytes"] - - # For backward compatibility: if caller explicitly provides index_name, - # respect it and do not regenerate; otherwise generate after flush. - explicit_index_name = query.get("index_name") - if explicit_index_name: - data["index_name"] = explicit_index_name - - # Create new record - new_record = KnowledgeRecord(**data) - session.add(new_record) - session.flush() - - # Generate internal index_name for new records when not explicitly provided - if not explicit_index_name: - generated_index_name = _generate_index_name( - new_record.knowledge_id) - new_record.index_name = generated_index_name - session.flush() - - session.commit() - return { - "knowledge_id": new_record.knowledge_id, - "index_name": new_record.index_name, - "knowledge_name": new_record.knowledge_name, - } + return _create_knowledge_record_in_session(session, query) except SQLAlchemyError as e: raise e @@ -170,8 +257,9 @@ def upsert_knowledge_record(query: Dict[str, Any]) -> Dict[str, Any]: "knowledge_name": existing_record.knowledge_name, } else: - # Create new record - return create_knowledge_record(query) + # Create in the same transaction as the existence check so the + # name lock and resource limits cannot be bypassed concurrently. + return _create_knowledge_record_in_session(session, query) except SQLAlchemyError as e: raise e diff --git a/backend/database/remote_mcp_db.py b/backend/database/remote_mcp_db.py index 4359f465d9..f63d73e245 100644 --- a/backend/database/remote_mcp_db.py +++ b/backend/database/remote_mcp_db.py @@ -1,10 +1,19 @@ import logging from typing import Any, Dict, List +from sqlalchemy import text + +from consts.const import MAX_MCP_SERVICES_PER_TENANT +from consts.exceptions import TenantResourceLimitError from database.client import as_dict, filter_property, get_db_session from database.db_models import McpRecord logger = logging.getLogger("remote_mcp_db") +_MCP_SERVICE_LIMIT = ( + MAX_MCP_SERVICES_PER_TENANT + if isinstance(MAX_MCP_SERVICES_PER_TENANT, int) + else 1_000 +) def create_mcp_record(mcp_data: Dict[str, Any], tenant_id: str, user_id: str): @@ -33,6 +42,27 @@ def create_mcp_record(mcp_data: Dict[str, Any], tenant_id: str, user_id: str): "delete_flag": "N" }) with get_db_session() as session: + # Serialize the count-and-insert sequence per tenant. Without the + # advisory lock, concurrent requests could both observe a free slot + # and exceed the hard tenant quota. + session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), + {"lock_key": f"tenant-mcp-limit:{tenant_id}"}, + ) + mcp_count = session.query(McpRecord).filter( + getattr(McpRecord, "tenant_id", None) == tenant_id, + getattr(McpRecord, "delete_flag", None) != "Y", + ).count() + mcp_count = mcp_count if isinstance(mcp_count, int) else 0 + if mcp_count >= _MCP_SERVICE_LIMIT: + raise TenantResourceLimitError( + "MCP service tenant limit reached: " + f"maximum {_MCP_SERVICE_LIMIT} MCP services per tenant", + resource="mcp_services", + scope="tenant", + limit=_MCP_SERVICE_LIMIT, + current_count=mcp_count, + ) new_mcp = McpRecord(**filtered_data) session.add(new_mcp) diff --git a/backend/database/skill_db.py b/backend/database/skill_db.py index 01ab50fa4a..5b1d23fc57 100644 --- a/backend/database/skill_db.py +++ b/backend/database/skill_db.py @@ -9,6 +9,9 @@ from database.client import get_db_session, filter_property, as_dict from database.db_models import SkillInfo, SkillToolRelation, SkillInstance, ToolInfo +from consts.const import MAX_SKILLS_PER_TENANT +from consts.error_code import ErrorCode +from consts.exceptions import AppException from utils.skill_params_utils import strip_params_comments_for_db from utils.str_utils import convert_list_to_string, convert_string_to_list @@ -35,7 +38,37 @@ def _params_value_for_db(raw: Any) -> Any: return json.loads(json.dumps(strip_params_comments_for_db(raw), default=str)) -def create_or_update_skill_by_skill_info(skill_info, tenant_id: str, user_id: str, version_no: int = 0): +def _raise_if_skill_limit_reached(session, tenant_id: str, additional_count: int = 1) -> None: + """Reject tenant-owned Skill creation when the hard quota would be exceeded.""" + if tenant_id is None or additional_count <= 0: + return + + current_count = session.query(SkillInfo).filter( + SkillInfo.tenant_id == tenant_id, + SkillInfo.delete_flag != "Y", + ).count() + # Lightweight test doubles may not implement count(); production SQLAlchemy + # sessions always return an integer. + if isinstance(current_count, int) and current_count + additional_count > MAX_SKILLS_PER_TENANT: + raise AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + f"Tenant skill limit reached: maximum {MAX_SKILLS_PER_TENANT} skills per tenant", + details={ + "resource": "skills", + "scope": "tenant", + "limit": MAX_SKILLS_PER_TENANT, + "current_count": current_count, + }, + ) + + +def create_or_update_skill_by_skill_info( + skill_info, + tenant_id: str, + user_id: str, + version_no: int = 0, + allow_system: bool = False, +): """ Create or update a SkillInstance in the database. Default version_no=0 operates on the draft version. @@ -49,9 +82,16 @@ def create_or_update_skill_by_skill_info(skill_info, tenant_id: str, user_id: st Returns: Created or updated SkillInstance object """ + from .agent_db import is_system_agent + skill_info_dict = skill_info.__dict__ if hasattr( skill_info, '__dict__') else skill_info skill_info_dict = skill_info_dict.copy() + if ( + not allow_system + and is_system_agent(skill_info_dict.get("agent_id"), tenant_id) is True + ): + raise ValueError("System Agent is managed by the platform") skill_info_dict.setdefault("tenant_id", tenant_id) skill_info_dict.setdefault("user_id", user_id) skill_info_dict.setdefault("version_no", version_no) @@ -166,8 +206,18 @@ def search_skills_for_agent(agent_id: int, tenant_id: str, version_no: int = 0): return [as_dict(skill_instance) for skill_instance in skill_instances] -def delete_skills_by_agent_id(agent_id: int, tenant_id: str, user_id: str, version_no: int = 0): +def delete_skills_by_agent_id( + agent_id: int, + tenant_id: str, + user_id: str, + version_no: int = 0, + allow_system: bool = False, +): """Delete all skill instances for an agent.""" + from .agent_db import is_system_agent + + if not allow_system and is_system_agent(agent_id, tenant_id) is True: + raise ValueError("System Agent is managed by the platform") with get_db_session() as session: session.query(SkillInstance).filter( SkillInstance.agent_id == agent_id, @@ -289,7 +339,7 @@ def _to_dict(skill: SkillInfo) -> Dict[str, Any]: "name": skill.skill_name, "tenant_id": skill.tenant_id, "description": skill.skill_description, - "tags": skill.skill_tags or [], + "tags": _normalize_skill_tags(skill.skill_tags), "content": skill.skill_content or "", "config_schemas": skill.config_schemas, "config_values": skill.config_values, @@ -303,6 +353,17 @@ def _to_dict(skill: SkillInfo) -> Dict[str, Any]: } +def _normalize_skill_tags(tags: Any) -> List[str]: + """Return skill tags as a string list, tolerating malformed persisted values.""" + if isinstance(tags, list): + return [ + tag.strip() for tag in tags if isinstance(tag, str) and tag.strip() + ] + if isinstance(tags, str) and tags.strip(): + return [tags.strip()] + return [] + + def list_skills(tenant_id: str) -> List[Dict[str, Any]]: """List all skills for a tenant from database. @@ -437,6 +498,8 @@ def create_skill(skill_data: Dict[str, Any], tenant_id: str) -> Dict[str, Any]: tenant_id: Tenant ID for the skill """ with get_db_session() as session: + _raise_if_skill_limit_reached(session, tenant_id) + skill = SkillInfo( skill_name=skill_data["name"], tenant_id=tenant_id, @@ -770,6 +833,14 @@ def upsert_scanned_skills(skills: List[Dict[str, Any]], user_id: str, tenant_id: ).all() existing_dict = {s.skill_name: s for s in existing_skills} + new_skill_names = { + skill_data.get("name") + for skill_data in skills + if skill_data.get("name") and skill_data.get("name") not in existing_dict + } + new_skill_count = len(new_skill_names) + _raise_if_skill_limit_reached(session, tenant_id, new_skill_count) + for skill_data in skills: skill_name = skill_data.get("name") if not skill_name: diff --git a/backend/database/tag_management_db.py b/backend/database/tag_management_db.py index b8f170dafd..c7e6c88150 100644 --- a/backend/database/tag_management_db.py +++ b/backend/database/tag_management_db.py @@ -892,6 +892,14 @@ def replace_resource_assignments( if value_id not in target_value_ids: session.delete(assignment) + # Flush deletes first: the unit of work emits INSERTs before + # DELETEs, so the capacity trigger would fire on a full replace. + if any( + value_id not in target_value_ids + for value_id in existing_by_value_id + ): + session.flush() + assignments = [ ResourceTagAssignment( tenant_id=tenant_id, diff --git a/backend/database/tenant_config_db.py b/backend/database/tenant_config_db.py index 38d46ff383..4d2f2333ee 100644 --- a/backend/database/tenant_config_db.py +++ b/backend/database/tenant_config_db.py @@ -7,12 +7,30 @@ from database.client import get_db_session from database.db_models import TenantConfig, TenantGroupInfo -from consts.const import DEFAULT_GROUP_ID, MAX_TENANT_COUNT, TENANT_ID, TENANT_NAME +from consts.const import ( + ASSET_OWNER_TENANT_ID, + DEFAULT_GROUP_ID, + DEFAULT_TENANT_ID, + MAX_TENANT_COUNT, + TENANT_ID, + TENANT_NAME, +) from consts.exceptions import TenantResourceLimitError logger = logging.getLogger("tenant_config_db") + +def _count_real_tenants(session) -> int: + """Count active tenant identities, excluding virtual/system tenants.""" + return session.query(TenantConfig.tenant_id).filter( + TenantConfig.config_key == TENANT_ID, + TenantConfig.delete_flag == "N", + TenantConfig.tenant_id.isnot(None), + TenantConfig.tenant_id.notin_(("", DEFAULT_TENANT_ID, ASSET_OWNER_TENANT_ID)), + ).distinct().count() + + def get_all_configs_by_tenant_id(tenant_id: str): with get_db_session() as session: result = session.query(TenantConfig).filter( @@ -80,7 +98,12 @@ def get_single_config_info(tenant_id: str, select_key: str): if result: record_info = { "config_value": result.config_value, - "tenant_config_id": result.tenant_config_id + "tenant_config_id": result.tenant_config_id, + # The UI config-save path (set_single_config) stamps the + # acting user here; auto-backfilled rows leave it empty. + # Consumers use it to tell "user chose this" from "system + # picked a placeholder". + "user_id": result.user_id, } return record_info @@ -96,14 +119,16 @@ def insert_config(insert_data: Dict[str, Any]): text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), {"lock_key": "tenant-count-limit"}, ) - tenant_count = session.query(TenantConfig.tenant_id).filter( - TenantConfig.config_key == TENANT_ID, - TenantConfig.delete_flag == "N", - ).distinct().count() + tenant_count = _count_real_tenants(session) if tenant_count >= MAX_TENANT_COUNT: - raise TenantResourceLimitError( + error = TenantResourceLimitError( f"Tenant limit reached: maximum {MAX_TENANT_COUNT} tenants" ) + error.resource = "tenants" + error.scope = "platform" + error.limit = MAX_TENANT_COUNT + error.current_count = tenant_count + raise error session.add(TenantConfig(**insert_data)) session.commit() return True @@ -124,14 +149,16 @@ def create_tenant_with_default_group( text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), {"lock_key": "tenant-count-limit"}, ) - tenant_count = session.query(TenantConfig.tenant_id).filter( - TenantConfig.config_key == TENANT_ID, - TenantConfig.delete_flag == "N", - ).distinct().count() + tenant_count = _count_real_tenants(session) if tenant_count >= MAX_TENANT_COUNT: - raise TenantResourceLimitError( + error = TenantResourceLimitError( f"Tenant limit reached: maximum {MAX_TENANT_COUNT} tenants" ) + error.resource = "tenants" + error.scope = "platform" + error.limit = MAX_TENANT_COUNT + error.current_count = tenant_count + raise error session.add(TenantConfig( tenant_id=tenant_id, diff --git a/backend/database/token_db.py b/backend/database/token_db.py index c94c2ffae0..8d22f11405 100644 --- a/backend/database/token_db.py +++ b/backend/database/token_db.py @@ -17,6 +17,17 @@ def generate_access_key() -> str: return f"nexent-{random_part}" +def mask_access_key(access_key: Optional[str]) -> Optional[str]: + """Mask an access key for list responses; only create/refresh returns the secret.""" + if not access_key: + return access_key + if len(access_key) <= 8: + return access_key[:2] + "****" + prefix, _, tail = access_key.rpartition("-") + shown_prefix = (prefix or access_key)[:8] + return f"{shown_prefix}****{access_key[-4:]}" + + def create_token( access_key: str, user_id: str, @@ -70,7 +81,7 @@ def list_tokens_by_user(user_id: str) -> List[Dict[str, Any]]: return [ { "token_id": token.token_id, - "access_key": token.access_key, + "access_key": mask_access_key(token.access_key), "user_id": token.user_id, "create_time": token.create_time.isoformat() if token.create_time else None } @@ -233,7 +244,7 @@ def list_active_tokens_by_tenant( "items": [ { "token_id": row.token_id, - "access_key": row.access_key, + "access_key": mask_access_key(row.access_key), "user_id": row.user_id, "created_by": row.created_by, "creator_email": row.creator_email, diff --git a/backend/database/tool_db.py b/backend/database/tool_db.py index 4d64eb95b0..cf608644b0 100644 --- a/backend/database/tool_db.py +++ b/backend/database/tool_db.py @@ -31,7 +31,13 @@ def create_tool(tool_info, version_no: int = 0): session.add(new_tool_instance) -def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str, version_no: int = 0): +def create_or_update_tool_by_tool_info( + tool_info, + tenant_id: str, + user_id: str, + version_no: int = 0, + allow_system: bool = False, +): """ Create or update a ToolInstance in the database. Default version_no=0 operates on the draft version. @@ -45,6 +51,11 @@ def create_or_update_tool_by_tool_info(tool_info, tenant_id: str, user_id: str, Returns: Created or updated ToolInstance object """ + from .agent_db import is_system_agent + + if not allow_system and is_system_agent(tool_info.agent_id, tenant_id) is True: + raise ValueError("System Agent is managed by the platform") + tool_info_dict = tool_info.__dict__ | { "tenant_id": tenant_id, "user_id": user_id, "version_no": version_no} @@ -472,7 +483,13 @@ def check_tool_is_available(tool_id_list: List[int]): return [tool.is_available for tool in tools] -def delete_tools_by_agent_id(agent_id, tenant_id, user_id, version_no: int = 0): +def delete_tools_by_agent_id( + agent_id, + tenant_id, + user_id, + version_no: int = 0, + allow_system: bool = False, +): """ Delete all tool instances for an agent. Default version_no=0 deletes the draft version. @@ -483,6 +500,10 @@ def delete_tools_by_agent_id(agent_id, tenant_id, user_id, version_no: int = 0): user_id: User ID version_no: Version number to filter. Default 0 = draft/editing state """ + from .agent_db import is_system_agent + + if not allow_system and is_system_agent(agent_id, tenant_id) is True: + raise ValueError("System Agent is managed by the platform") with get_db_session() as session: session.query(ToolInstance).filter( ToolInstance.agent_id == agent_id, diff --git a/backend/database/user_tenant_db.py b/backend/database/user_tenant_db.py index b208347619..a5ec5b262d 100644 --- a/backend/database/user_tenant_db.py +++ b/backend/database/user_tenant_db.py @@ -46,9 +46,14 @@ def _validate_user_tenant_limit( getattr(UserTenant, "delete_flag", None) == "N", ).count()) if user_count >= _USER_LIMIT: - raise TenantResourceLimitError( + error = TenantResourceLimitError( f"Tenant user limit reached: maximum {_USER_LIMIT} users per tenant" ) + error.resource = "users" + error.scope = "tenant" + error.limit = _USER_LIMIT + error.current_count = user_count + raise error normalized_role = (user_role or "").upper() if normalized_role == "ADMIN": @@ -59,9 +64,14 @@ def _validate_user_tenant_limit( getattr(UserTenant, "delete_flag", None) == "N", ).count()) if admin_count >= _ADMIN_LIMIT: - raise TenantResourceLimitError( + error = TenantResourceLimitError( f"Tenant administrator limit reached: maximum {_ADMIN_LIMIT} administrators per tenant" ) + error.resource = "administrators" + error.scope = "tenant" + error.limit = _ADMIN_LIMIT + error.current_count = admin_count + raise error elif normalized_role == "SU": _lock_resource_limit(session, "super-admin-limit") super_admin_count = _count_or_zero(session.query(UserTenant).filter( @@ -69,9 +79,14 @@ def _validate_user_tenant_limit( getattr(UserTenant, "delete_flag", None) == "N", ).count()) if super_admin_count >= _SUPER_ADMIN_LIMIT: - raise TenantResourceLimitError( + error = TenantResourceLimitError( f"Super administrator limit reached: maximum {_SUPER_ADMIN_LIMIT} super administrator" ) + error.resource = "super_admins" + error.scope = "platform" + error.limit = _SUPER_ADMIN_LIMIT + error.current_count = super_admin_count + raise error def get_user_role_by_tenant(user_id: str, tenant_id: str) -> str: diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index 23e7684198..a53626a4e7 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -18,27 +18,25 @@ import time from http import HTTPStatus from typing import Annotated, List, Optional +from uuid import UUID -from fastapi import APIRouter, File, Path, Query, Request, UploadFile -from fastapi.responses import JSONResponse +from fastapi import APIRouter, File, HTTPException, Path, Query, Request, UploadFile +from fastapi.responses import JSONResponse, StreamingResponse +from nexent.core.concurrency import run_blocking from pydantic import BaseModel, Field from sqlalchemy.exc import IntegrityError -from nexent.core.concurrency import run_blocking +from starlette.background import BackgroundTask from consts.const import AIDP_API_KEY, AIDP_SERVER_URL from consts.error_code import ErrorCode from consts.exceptions import AppException, UnauthorizedError from database.user_tenant_db import get_user_role_by_tenant from ext_components.aidp.consts.aidp_exceptions import ( - AidpKbConflictError, - AidpKbNotFoundError, - AidpKbPermissionDeniedError, - AidpKbSyncError, AidpGroupValidationError, + AidpKbConflictError, ) from ext_components.aidp.database import aidp_permission_db from ext_components.aidp.services import aidp_permission_service as perms -from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings from ext_components.aidp.services.aidp_access_service import ( get_cached_aidp_channels, get_cached_aidp_doc_count, @@ -48,6 +46,13 @@ invalidate_aidp_kb_detail_cache, resolve_current_aidp_access, ) +from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings +from ext_components.aidp.services.aidp_permission_service import ( + EDIT, + PRIVATE, + READ_ONLY, + _validate_group_ids_strict, # noqa: F401 - retained as a module-level compatibility symbol +) from ext_components.aidp.services.aidp_service import ( _timestamp_to_iso, count_aidp_docs_impl, @@ -58,18 +63,15 @@ list_aidp_doc_history_impl, list_aidp_docs_impl, list_aidp_models_impl, + remove_aidp_docs_impl, select_aidp_channel, + stream_aidp_doc_impl, update_aidp_kb_impl, upload_aidp_docs_impl, ) -from ext_components.aidp.services.aidp_permission_service import ( - EDIT, - PRIVATE, - READ_ONLY, - _validate_group_ids_strict, -) from utils import auth_utils as auth_utils_module + aidp_mgmt_router = APIRouter(prefix="/aidp-mgmt") logger = logging.getLogger("aidp_mgmt_app") @@ -84,7 +86,9 @@ # every other reported status is counted as work in progress: `processing_count` # is what keeps the frontend polling, and a build that reports a stage we do not # know yet must not stop it early either. -_TERMINAL_DOC_STATUSES = ("COMPLETED", "FAILED") +_DOC_STATUS_COMPLETED = "COMPLETED" +_DOC_STATUS_FAILED = "FAILED" +_TERMINAL_DOC_STATUSES = (_DOC_STATUS_COMPLETED, _DOC_STATUS_FAILED) def _upload_failure(file_name: str, reason_zh: str, reason_en: str) -> dict: @@ -199,6 +203,17 @@ class SetPermissionRequest(BaseModel): ) +class RemoveAidpDocumentsRequest(BaseModel): + + file_uuids: List[UUID] = Field(..., min_length=1, description="AIDP file UUIDs") + + +class DownloadAidpDocumentRequest(BaseModel): + """AIDP file selected for download.""" + + file_uuid: UUID = Field(..., description="AIDP file UUID") + + # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- @@ -255,11 +270,6 @@ def _raise_aidp_conflict(exc: IntegrityError) -> None: ) -# HTTPException is imported lazily to keep FastAPI's exception handler in -# control of the response body. -from fastapi import HTTPException # noqa: E402 (placed here to avoid editing mid-file) - - def _credentials() -> tuple[str, str]: return AIDP_SERVER_URL, AIDP_API_KEY @@ -396,6 +406,112 @@ def _resolve_doc_history_channel( return channel +# How many history pages one document-list request may read. The endpoint is +# paginated and puts files that are still being processed in front, so a burst +# of simultaneous uploads can spill past the first page. +_HISTORY_PAGE_LIMIT = 20 + + +def _history_reports_more(payload: dict) -> bool | None: + """Whether the payload explicitly says another history page exists. + + Only unambiguous signals are trusted: this endpoint family reports + ``total_count`` as the size of the current page elsewhere, so it cannot be + read as a grand total. ``None`` means the payload does not say, and the + caller has to ask for the next page to find out. + """ + has_more = payload.get("has_more") + if isinstance(has_more, bool): + return has_more + if "next_link" in payload: + return bool(payload.get("next_link")) + return None + + +async def _load_doc_history_items( + server_url: str, + api_key: str, + kds_id: str, + fs_id: str, + dir_path: str, +) -> list[dict]: + """Read the channel directory's all-status history across its pages. + + Reading only the first page would drop precisely the files this listing + exists to show: the endpoint sorts files that are still being processed to + the front, so more simultaneous uploads than fit in a page push the rest out + of view. + + The walk stops at an empty page, stops when the payload says there is no + further page, and stops when a page adds nothing new — the last one keeps a + build that ignores ``page`` from looping over the same files. It is capped + at ``_HISTORY_PAGE_LIMIT`` so one list request cannot turn into an unbounded + number of upstream calls; reaching the cap is logged because the files + beyond it are then unknown. + """ + collected: list[dict] = [] + seen: set[str] = set() + for page in range(1, _HISTORY_PAGE_LIMIT + 1): + payload = await run_blocking( + "aidp-doc-history", + list_aidp_doc_history_impl, + server_url, + api_key, + fs_id, + dir_path, + kds_id, + None, + page, + lane="control-io", + owner="config", + ) + raw_items = payload.get("value") if isinstance(payload, dict) else None + page_items = ( + [item for item in raw_items if isinstance(item, dict)] + if isinstance(raw_items, list) + else [] + ) + if not page_items: + return collected + + added = 0 + for item in page_items: + identities = _document_identities(item) + key = identities[0] if identities else f"anonymous-{page}-{len(collected)}" + if key in seen: + continue + seen.add(key) + collected.append(item) + added += 1 + + reported_more = ( + _history_reports_more(payload) if isinstance(payload, dict) else None + ) + if reported_more is False: + return collected + if added == 0: + # The same page came back again, so this build does not honour + # `page`: stop instead of looping over the same files, but say so, + # because everything beyond the first page stays invisible. + logger.warning( + "AIDP file history for KB %s answered page %d without new items (%d read); " + "the endpoint appears to ignore `page`, so files beyond the first page stay " + "invisible", + kds_id, + page, + len(collected), + ) + return collected + logger.warning( + "AIDP file history for KB %s reached %d pages (%d files); the statuses of further " + "files are not read", + kds_id, + _HISTORY_PAGE_LIMIT, + len(collected), + ) + return collected + + async def _load_doc_history( server_url: str, api_key: str, @@ -422,19 +538,14 @@ async def _load_doc_history( if not channel: # The reason is reported by ``_resolve_doc_history_channel``. return None - history = await run_blocking( - "aidp-doc-history", - list_aidp_doc_history_impl, + items = await _load_doc_history_items( server_url, api_key, + kds_id, channel["fs_id"], channel["src_dir"], - kds_id, - lane="control-io", - owner="config", ) - items = history.get("value") if isinstance(history, dict) else None - if isinstance(items, list) and not items: + if not items: # A resolved channel directory holding no file would blank the table # and hide the KB's ingested files — the directory may simply not be # where this KB's uploads live. The KB-scoped listing is always safe @@ -446,7 +557,7 @@ async def _load_doc_history( f"(fs_id={channel['fs_id']}, dir_path={channel['src_dir']})", ) return None - return history + return {"value": items} except AppException as exc: _log_history_fallback(kds_id, f"history request failed: {exc}") return None @@ -455,30 +566,53 @@ async def _load_doc_history( return None -def _history_sort_key(item: dict) -> tuple: - """Sort key placing the most recently uploaded file first. +def _is_processing_status(status: object) -> bool: + """Whether a reported status still walks the ingestion stages. - Numeric upload timestamps sort above entries that only expose an ISO - ``created_at`` string, and entries with neither sink to the bottom — the - ordering is only ever used to bring fresh uploads to the top of page 1. + ``UPLOADING``, ``PROCESSING`` and ``EXTRACTING`` are the open stages and + ``COMPLETED``/``FAILED`` the terminal ones. An unrecognised stage counts as + open as well: a build reporting a status this module does not know yet must + not be mistaken for a finished file, which would both stop the polling and + drop the row behind the ingested ones. """ + return ( + isinstance(status, str) + and status.strip().upper() not in _TERMINAL_DOC_STATUSES + ) + + +def _history_sort_key(item: dict) -> tuple: + """Sort key placing files still being processed above finished ones. + + A freshly accepted upload reports ``UPLOADING``/``EXTRACTING``/``PROCESSING`` + before it is ingested, and that row is what the user is looking for right + after an upload because it carries the progress of the file they just added. + Those files therefore sort above the finished ones whatever their timestamps + say. Inside each group files are ordered newest first, with numeric upload + timestamps above entries that only expose an ISO ``created_at`` string and + entries with neither sinking to the bottom. + """ + in_progress = 1 if _is_processing_status(item.get("status")) else 0 raw = item.get("first_upload_time") if raw is None: raw = item.get("create_time") try: - return (1, float(raw)) + return (in_progress, 1, float(raw)) except (TypeError, ValueError): created_at = item.get("created_at") - return (0, created_at) if isinstance(created_at, str) else (0, "") + if isinstance(created_at, str): + return (in_progress, 0, created_at) + return (in_progress, 0, "") def _paginate_history_documents(result: dict, page: int, page_size: int) -> dict: """Slice an all-status history payload into one page. The history API returns the whole channel directory in one response, so the - total is exact and the document Count endpoint is not needed. Newest files - come first so an upload shows up at the top of page 1 as soon as it is - accepted, instead of only after ingestion completes. + total is exact and the document Count endpoint is not needed. Files that are + still uploading or extracting come first, because the user has to see the + progress of the file they just added; the finished files follow, newest + first, so a completed upload stays near the top of its own group. ``processing_count`` covers the WHOLE directory, not just the returned page: the frontend keeps polling while it is non-zero, so a file still being @@ -496,10 +630,7 @@ def _paginate_history_documents(result: dict, page: int, page_size: int) -> dict # Count every non-terminal status, so a file that is uploading or extracting # keeps the frontend polling exactly like one that is being chunked. processing_count = sum( - 1 - for item in ordered - if isinstance(item.get("status"), str) - and item["status"].strip().upper() not in _TERMINAL_DOC_STATUSES + 1 for item in ordered if _is_processing_status(item.get("status")) ) return { "value": ordered[start:end], @@ -510,6 +641,164 @@ def _paginate_history_documents(result: dict, page: int, page_size: int) -> dict } +# The all-status history is directory-scoped while the document listing is +# knowledge-base scoped, so the two sources disagree on membership. The listing +# is read in pages of at most this size, and capped so one list request cannot +# turn into an unbounded number of upstream calls on a very large knowledge base. +_INGESTED_PAGE_SIZE = 100 +_INGESTED_MAX_PAGES = 20 + + +def _document_identities(item: dict) -> list[str]: + """Return every identity a history entry / listed file exposes. + + Both payloads describe the same file through ``file_uuid`` and + ``file_ino_no``, but a payload may carry only one of them, so the merge + matches on either value instead of picking a single preferred field. + """ + identities: list[str] = [] + for field in ("file_uuid", "file_ino_no"): + value = item.get(field) + if value is None or value == "": + continue + identities.append(str(value)) + return identities + + +def _matching_key(identities: list[str], known_ids: dict[str, str]) -> str: + """Return the key an item already occupies, or its first identity. + + The same file can be described with a uuid by one payload and with an ino + number by the other, so an item is matched through every identity it exposes + before it is treated as a new row. + """ + for value in identities: + known = known_ids.get(value) + if known is not None: + return known + return identities[0] + + +def _merge_document_sources( + history_items: list[dict], + listed_items: list[dict], +) -> list[dict]: + """Union the channel history with the knowledge-base document listing. + + The two sources disagree on membership: the history covers the resolved + channel directory, while the listing covers every ingested file of the + knowledge base. Reading only the history hides files that were ingested + into another directory — a knowledge base migrated from an older release, or + one fed by a second channel — which looks like files disappearing from the + list as soon as the resolved directory stops being empty. Reading only the + listing hides uploads that are still being processed, which is what the + history is there for. + + Merging keeps both visible: the listing guarantees membership and carries + the file metadata, the history supplies the live statuses, and a file only + the history knows about (still uploading, or failed before ingestion) is + kept exactly as reported. A file present in both is combined field by field, + so a history build whose payload omits the metadata fields cannot blank out + the name, size or creation time the listing already describes. Items are + matched through every identity they expose, so a file that one payload + describes with a uuid and the other with an ino number is still one row. + """ + merged: dict[str, dict] = {} + # Any identity -> the key its item is stored under, so a later payload can + # find the row even when it only carries the other id field. + known_ids: dict[str, str] = {} + for item in listed_items: + identities = _document_identities(item) + if not identities: + continue + key = _matching_key(identities, known_ids) + # The completed-files listing only ever returns ingested files, so a + # file taken from it is finished by definition. + merged[key] = {**item, "status": _DOC_STATUS_COMPLETED} + for value in identities: + known_ids[value] = key + for item in history_items: + identities = _document_identities(item) + if not identities: + continue + key = _matching_key(identities, known_ids) + listed = merged.get(key) + if listed is None: + # The file is not ingested yet (or was ingested into another + # directory), so the history entry is all there is to show. + merged[key] = item + else: + # The history reports the status of the file right now, but its + # payload may be minimal: replacing the listing row wholesale would + # blank out every metadata field the history does not carry, which + # the user sees as an empty name or creation time. Only the fields + # the history actually reports win, the rest stays as listed. + reported = { + field: value + for field, value in item.items() + if value is not None and value != "" + } + merged[key] = {**listed, **reported} + for value in identities: + known_ids[value] = key + return list(merged.values()) + + +async def _load_ingested_documents( + server_url: str, + api_key: str, + kds_id: str, +) -> list[dict]: + """Read every ingested file of ``kds_id`` across the listing's pages. + + A knowledge base can hold files the channel history does not cover, so the + listing is what guarantees membership. The walk stops at the first short + page and is capped at ``_INGESTED_MAX_PAGES``; reaching the cap is logged, + because the union would then be incomplete. A failing listing degrades to + whatever was already read instead of failing the request. + """ + collected: list[dict] = [] + for index in range(_INGESTED_MAX_PAGES): + try: + payload = await run_blocking( + "aidp-list-documents", + list_aidp_docs_impl, + server_url, + api_key, + kds_id, + index + 1, + _INGESTED_PAGE_SIZE, + lane="control-io", + owner="config", + ) + except Exception as exc: # noqa: BLE001 - the history can stand alone + logger.warning( + "AIDP document listing page %d failed for KB %s (%r); the document list " + "keeps the files read so far", + index + 1, + kds_id, + exc, + ) + return collected + raw_items = payload.get("value") if isinstance(payload, dict) else None + page_items = ( + [item for item in raw_items if isinstance(item, dict)] + if isinstance(raw_items, list) + else [] + ) + collected.extend(page_items) + if len(page_items) < _INGESTED_PAGE_SIZE: + return collected + logger.warning( + "AIDP document listing for KB %s reached %d pages (%d files); files beyond that " + "are not merged into the document list", + kds_id, + _INGESTED_MAX_PAGES, + len(collected), + ) + return collected + + # --------------------------------------------------------------------------- # Handlers # --------------------------------------------------------------------------- @@ -931,20 +1220,36 @@ async def list_documents( # Preferred source: the all-status history, so files appear in the list # while they are still being chunked/embedded (and when they failed). The - # payload covers the whole channel directory, so this branch paginates - # in-process and reports an exact total. + # history is scoped to the resolved channel directory though, so it is + # merged with the knowledge-base scoped listing instead of replacing it: + # reading the history alone hides every file that was ingested somewhere + # else, which shows up as those files vanishing from the list. history_result = await _load_doc_history(server_url, api_key, kds_id) if history_result is not None: - result = _paginate_history_documents(history_result, page, page_size) + raw_history_items = history_result.get("value") + history_items = ( + [item for item in raw_history_items if isinstance(item, dict)] + if isinstance(raw_history_items, list) + else [] + ) + listed_items = await _load_ingested_documents(server_url, api_key, kds_id) + merged_items = _merge_document_sources(history_items, listed_items) + # The merged set is complete in one response, so this branch paginates + # in-process and reports an exact total. + result = _paginate_history_documents({"value": merged_items}, page, page_size) logger.info( "AIDP document list timing: total_ms=%.1f kb_id=%s page=%d page_size=%d " - "page_count=%d total_count=%d total_reliable=True source=history", + "page_count=%d total_count=%d total_reliable=True source=history+listing " + "history_items=%d listed_items=%d merged_items=%d", (time.perf_counter() - started_at) * 1000, kds_id, page, page_size, len(result["value"]), result["total_count"], + len(history_items), + len(listed_items), + len(merged_items), ) return JSONResponse(status_code=HTTPStatus.OK, content=result) @@ -1018,6 +1323,65 @@ async def list_documents( return JSONResponse(status_code=HTTPStatus.OK, content=result) +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/remove") +async def remove_documents( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: RemoveAidpDocumentsRequest, +) -> JSONResponse: + """Remove AIDP documents.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") + + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-remove-documents", + remove_aidp_docs_impl, + server_url, + api_key, + kds_id, + [str(file_uuid) for file_uuid in body.file_uuids], + lane="control-io", + owner="config", + ) + + success_list = result["success_list"] + if success_list: + invalidate_aidp_kb_detail_cache(server_url, api_key, kds_id) + invalidate_aidp_doc_count_cache(server_url, api_key, kds_id) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/download") +async def download_document( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: DownloadAidpDocumentRequest, +) -> StreamingResponse: + """Proxy an AIDP document as a binary attachment.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + + server_url, api_key = _credentials() + aidp_response = await stream_aidp_doc_impl( + server_url, + api_key, + kds_id, + str(body.file_uuid), + ) + response_headers = { + "Content-Disposition": aidp_response.headers["Content-Disposition"], + "X-File-Size": aidp_response.headers["X-File-Size"], + } + + return StreamingResponse( + aidp_response.aiter_bytes(), + media_type=aidp_response.headers["Content-Type"], + headers=response_headers, + background=BackgroundTask(aidp_response.aclose), + ) + + @aidp_mgmt_router.patch("/aidp-permissions/{kds_id}") async def set_permission( request: Request, diff --git a/backend/ext_components/aidp/services/aidp_service.py b/backend/ext_components/aidp/services/aidp_service.py index 4116e5feea..aee98f6970 100644 --- a/backend/ext_components/aidp/services/aidp_service.py +++ b/backend/ext_components/aidp/services/aidp_service.py @@ -5,15 +5,16 @@ import logging import time from datetime import datetime, timezone -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, NoReturn from urllib.parse import quote, urljoin import httpx +from nexent.utils.http_client_manager import http_client_manager from consts.const import AIDP_TENANT_ID from consts.error_code import ErrorCode from consts.exceptions import AppException -from nexent.utils.http_client_manager import http_client_manager + logger = logging.getLogger("aidp_service") @@ -66,6 +67,39 @@ def _extract_upstream_error(response: httpx.Response) -> str | None: return reason[:_MAX_UPSTREAM_ERROR_REASON_LENGTH] +def _raise_aidp_http_error(error: httpx.HTTPStatusError, operation: str) -> NoReturn: + """Map an AIDP HTTP error to the common application exception format.""" + response = error.response + upstream_reason = _extract_upstream_error(response) + logger.exception( + "AIDP %s HTTP error: status_code=%s upstream_reason=%s", + operation, + response.status_code, + upstream_reason or "unavailable", + ) + details = { + "upstream_status": response.status_code, + "upstream_reason": upstream_reason, + } + error_code = { + 401: ErrorCode.AIDP_AUTH_ERROR, + 403: ErrorCode.AIDP_AUTH_ERROR, + 429: ErrorCode.AIDP_RATE_LIMIT, + }.get(response.status_code, ErrorCode.AIDP_SERVICE_ERROR) + fallback_message = { + ErrorCode.AIDP_AUTH_ERROR: f"AIDP authentication failed: {str(error)}", + ErrorCode.AIDP_RATE_LIMIT: f"AIDP rate limit exceeded: {str(error)}", + }.get( + error_code, + f"AIDP API HTTP error {response.status_code}: {str(error)}", + ) + raise AppException( + error_code, + upstream_reason or fallback_message, + details=details, + ) + + def _extract_upload_failures(response: httpx.Response) -> List[Dict[str, str]]: """Extract per-file upload failures from AIDP's structured error body.""" try: @@ -207,20 +241,68 @@ def _extract_list_payload(payload: Any) -> list | None: return None +def _timestamp_or_iso(value: Any) -> str | None: + """Return an ISO-8601 string for a Unix timestamp or an already-ISO value. + + AIDP spells the creation time two ways: the document listing reports + ``first_upload_time`` / ``create_time`` as Unix seconds, while the + knowledge-file history already sends the canonical ``created_at`` as an ISO + string. Both spellings have to survive normalization, otherwise the history + rows lose a column the listing rows keep. + """ + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + float(text) + except ValueError: + # Already an ISO-8601 string: keep it verbatim. + return text + value = text + return _timestamp_to_iso(value) + + +# Spellings AIDP uses for a document timestamp, in priority order. The history +# endpoint sends the canonical ``created_at``, the listing reports the upload +# stamp, and ``update_time`` closes the chain because a file AIDP has not +# finished registering yet reports no creation time at all — for a freshly +# uploaded file the update stamp is the moment it was accepted, which beats +# leaving the column empty. +_CREATED_AT_KEYS = ("first_upload_time", "create_time", "created_at", "update_time") +_UPDATED_AT_KEYS = ("update_time", "updated_at") + + +def _first_reported(raw: Dict[str, Any], keys: tuple[str, ...]) -> Any: + """Return the first value ``raw`` reports for ``keys``, skipping blanks. + + ``None``, an empty string and ``False`` all mean "not reported": AIDP sends + any of them for unset fields. Treating the blank spelling as a value would + shadow the next key in the chain, which is how an empty ``create_time`` hid a + populated ``created_at`` and left the creation time null. + """ + for key in keys: + value = raw.get(key) + if value is None or value == "" or value is False: + continue + return value + return None + + def _normalize_aidp_doc(raw: Dict[str, Any]) -> Dict[str, Any]: """Map an AIDP document item to the shape the frontend expects. - AIDP returns ``first_upload_time`` / ``create_time`` as the creation timestamp - and ``update_time`` as the last-modified timestamp. The frontend schema - expects ``created_at`` (ISO string). This mapper performs that conversion - and carries through all other fields unchanged. + AIDP spells the timestamps several ways: the document listing reports + ``first_upload_time`` / ``create_time`` as Unix seconds, while the + knowledge-file history already sends the canonical ``created_at`` as an ISO + string, and either side may send an unset field as ``""``. Both spellings + therefore have to be accepted, and blank ones skipped, so a history row keeps + the creation time instead of losing it here. All other fields are carried + through unchanged. """ out = dict(raw) - created_raw = raw.get("first_upload_time") or raw.get("create_time") - out["created_at"] = _timestamp_to_iso(created_raw) - - updated_raw = raw.get("update_time") - out["updated_at"] = _timestamp_to_iso(updated_raw) + out["created_at"] = _timestamp_or_iso(_first_reported(raw, _CREATED_AT_KEYS)) + out["updated_at"] = _timestamp_or_iso(_first_reported(raw, _UPDATED_AT_KEYS)) return out @@ -330,6 +412,7 @@ def _validate_params(server_url: str, api_key: str) -> str: _AIDP_RETRY_BACKOFF_FACTOR = 0.5 _AIDP_RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504} _AIDP_READ_TIMEOUT_SECONDS = 30.0 +_AIDP_DOWNLOAD_TIMEOUT_SECONDS = 120.0 def _request_with_retry( @@ -1214,6 +1297,107 @@ def upload_aidp_docs_impl( ) +def remove_aidp_docs_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuids: List[str], +) -> Dict[str, Any]: + """Remove one or more documents from an AIDP knowledge base.""" + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + remove_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Remove" + remove_url = urljoin(f"{normalized_url}/", remove_path) + logger.info("Removing %d AIDP documents from %s", len(file_uuids), remove_url) + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=_AIDP_READ_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = _request_with_retry( + lambda: client.post( + remove_url, + headers=headers, + json={"file_uuids": file_uuids}, + ), + context=f"remove-docs:{kds_id}", + ) + response.raise_for_status() + result = response.json() + return result + except httpx.RequestError as e: + logger.exception("AIDP document removal request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + _raise_aidp_http_error(e, "document removal") + except ValueError as e: + logger.exception("Failed to parse AIDP document removal response: %s", e) + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + f"Failed to parse AIDP API response: {str(e)}", + ) + + +async def stream_aidp_doc_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuid: str, +) -> httpx.Response: + """Open a streaming response for one AIDP document.""" + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + download_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Download" + download_url = urljoin(f"{normalized_url}/", download_path) + logger.info("Downloading AIDP document %s from %s", file_uuid, download_url) + + response: httpx.Response | None = None + try: + client = http_client_manager.get_async_client( + base_url=normalized_url, + timeout=_AIDP_DOWNLOAD_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = await client.send( + client.build_request( + "POST", + download_url, + headers=headers, + json={"file_uuid": file_uuid}, + ), + stream=True, + ) + if response.status_code >= 400: + await response.aread() + response.raise_for_status() + return response + except httpx.RequestError as e: + if response is not None: + await response.aclose() + logger.exception("AIDP document download request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + if response is not None: + await response.aclose() + _raise_aidp_http_error(e, "document download") + + def count_aidp_docs_impl(server_url: str, api_key: str, kds_id: str) -> int: """Get total document count in a KB via AIDP POST .../Count endpoint. @@ -1596,18 +1780,25 @@ def list_aidp_doc_history_impl( dir_path: str, kds_id: str, tenant_id: str | None = None, + page: int = 1, ) -> Dict[str, Any]: - """List every file in a channel directory regardless of processing status. + """List a page of a channel directory regardless of processing status. Endpoint: ``POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{kds_id}/KnowledgeFiles/History`` - Body: ``{"fs_id": , "dir_path": }`` + Body: ``{"fs_id": , "dir_path": , "page": }`` Response: ``{"value": [, ...]}`` Unlike ``list_aidp_docs_impl`` this returns files that are still being chunked/embedded (``PROCESSING``) or that failed (``FAILED``), which is what lets the UI show an upload immediately instead of only after ingestion. + + The endpoint is paginated (``page`` is one-based) and sorts files that are + still being processed to the front, so a burst of simultaneous uploads can + spill past the first page: callers must walk the pages instead of reading + only the first one. """ normalized_url = _validate_params(server_url, api_key) + normalized_page = page if isinstance(page, int) and page > 0 else 1 if not isinstance(kds_id, str) or not kds_id.strip(): raise AppException( @@ -1646,7 +1837,11 @@ def list_aidp_doc_history_impl( lambda: client.post( history_url, headers=headers, - json={"fs_id": normalized_fs_id, "dir_path": normalized_dir_path}, + json={ + "fs_id": normalized_fs_id, + "dir_path": normalized_dir_path, + "page": normalized_page, + }, ), context=f"list-doc-history:{normalized_fs_id}", ) @@ -1823,3 +2018,81 @@ def list_aidp_models_impl( ErrorCode.AIDP_RESPONSE_ERROR, f"Failed to parse AIDP models response: {str(e)}", ) + + +def _get_retrieval_path(tenant_id: str | None = None) -> str: + """Build the tenant-scoped retrieval (FusionSearch) API path.""" + return f"/KnowledgeBase/Tenants/{_resolve_tenant_id(tenant_id)}/Retrieval/FusionSearch" + + +def fusion_search_impl( + server_url: str, + api_key: str, + tenant_id: str | None = None, + query: str = "", + kds_list: list[str] | None = None, + search_method: str = "hybrid_search", + top_k: int = 3, + score_threshold: float = 0.0, + reranking_enable: bool = True, + rewrite_enable: bool = False, + related_search_enable: bool = False, + multi_modal: bool = False, +) -> list[dict[str, Any]]: + """Run one FusionSearch retrieval against AIDP and return raw hit records. + + Mirrors the SDK ``AidpSearchTool`` wire contract so server-side callers + (evaluation case generation) retrieve through the same channel as agents. + Records come back as ``{title, text, score, file_url, chunk_type, ...}``. + """ + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "query": query, + "kds_list": [str(kds_id) for kds_id in kds_list], + "search_method": search_method, + "reranking_enable": reranking_enable, + "rewrite_enable": rewrite_enable, + "related_search_enable": related_search_enable, + "score_threshold": score_threshold, + "top_k": top_k, + "multi_modal": multi_modal, + } + retrieval_url = urljoin(f"{normalized_url}/", _get_retrieval_path(tenant_id)) + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=_AIDP_READ_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = _request_with_retry( + lambda: client.post(retrieval_url, headers=headers, json=payload), + context="fusion-search", + ) + response.raise_for_status() + result = response.json() + except httpx.RequestError as e: + logger.exception("AIDP fusion search request failed") + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP fusion search request failed: {e!s}", + ) + except httpx.HTTPStatusError as e: + _raise_aidp_http_error(e, "fusion search") + except ValueError as e: + logger.exception("Failed to parse AIDP fusion search response") + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + f"Failed to parse AIDP fusion search response: {e!s}", + ) + + records = result.get("result", []) if isinstance(result, dict) else [] + if not isinstance(records, list): + logger.warning("AIDP fusion search returned non-list result field") + return [] + return records diff --git a/backend/management/services/agent/icon_storage.py b/backend/management/services/agent/icon_storage.py new file mode 100644 index 0000000000..6822329ab6 --- /dev/null +++ b/backend/management/services/agent/icon_storage.py @@ -0,0 +1,45 @@ +"""Image validation and object storage shared by agent and repository icons.""" + +import io + +ICON_MAX_BYTES = 2 * 1024 * 1024 + + +def validate_icon_image(content: bytes) -> str: + if not content: + raise ValueError("Icon file is empty") + if len(content) > ICON_MAX_BYTES: + raise ValueError("Icon must not exceed 2 MB") + + if content.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if content.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if content.startswith((b"GIF87a", b"GIF89a")): + return "image/gif" + if len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP": + return "image/webp" + raise ValueError("Icon must be a PNG, JPEG, GIF, or WebP image") + + +def upload_icon_image(content: bytes, object_name: str) -> str: + from database.client import minio_client + + content_type = validate_icon_image(content) + success, error = minio_client.upload_fileobj(io.BytesIO(content), object_name) + if not success: + raise ValueError(f"Failed to upload icon: {error}") + return content_type + + +def read_icon_image(object_name: str) -> tuple[bytes, str]: + from database.attachment_db import get_file_stream + + stream = get_file_stream(object_name) + if stream is None: + raise FileNotFoundError("Icon not found") + content = stream.read() + try: + return content, validate_icon_image(content) + except ValueError as exc: + raise FileNotFoundError("Icon is invalid") from exc diff --git a/backend/management/services/agent/management.py b/backend/management/services/agent/management.py index 934e4d514f..8301042879 100644 --- a/backend/management/services/agent/management.py +++ b/backend/management/services/agent/management.py @@ -12,10 +12,13 @@ from fastapi.responses import JSONResponse from agents.create_agent_info import create_tool_config_list +from utils.agent_transfer_utils import portable_tool_params, validate_import_tool_params from services.agent_version_service import publish_version_impl +from consts.agent_repository import STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED from consts.const import TOOL_TYPE_MAPPING, \ MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_PRIVATE from consts.exceptions import ( + ForbiddenError, SkillDuplicateError, ) from consts.model import ( @@ -40,7 +43,10 @@ delete_agent_relationship, delete_related_agent, insert_related_agent, + is_system_agent, query_all_agent_info_by_tenant_id, + query_agent_info_by_ids, + query_agent_list_candidates_by_tenant_id, query_sub_agent_relations, query_sub_agents_id_list, resolve_sub_agent_version_no, @@ -62,10 +68,12 @@ ) from database import skill_db from management.services.skill.service import SkillService -from database.agent_version_db import query_version_list +from database.agent_repository_db import list_agent_repository_by_agent_ids +from database.agent_version_db import batch_search_version_names, query_version_list from database.group_db import query_group_ids_by_user from database.user_tenant_db import get_user_tenant_by_user_id from database.a2a_agent_db import get_server_agent_ids +from database.tag_management_db import TagManagementDB from services.prompt_template_service import ( SYSTEM_PROMPT_TEMPLATE_ID, SYSTEM_PROMPT_TEMPLATE_NAME, @@ -231,6 +239,9 @@ async def delete_agent_impl(agent_id: int, tenant_id: str, user_id: str): tenant_id: Tenant ID user_id: User ID performing the deletion """ + if is_system_agent(agent_id, tenant_id) is True: + raise ForbiddenError("System Agent is managed by the platform") + try: try: agent = search_agent_info_by_agent_id(agent_id, tenant_id) @@ -261,6 +272,9 @@ async def _export_agent_dict_core( version_no: int = 0, ) -> dict: """Build ExportAndImportDataFormat dict for an agent tree at the given version.""" + if is_system_agent(root_agent_id, tenant_id) is True: + raise ForbiddenError("System Agent cannot be exported") + export_agent_dict = {} search_list: deque = deque([(root_agent_id, version_no)]) visited: set = set() @@ -478,6 +492,9 @@ async def export_agent_by_agent_id( # Check if any tool is KnowledgeBaseSearchTool and set its metadata to empty dict for tool in tool_list: + if tool.class_name == "AidpSearchTool": + tool.params = portable_tool_params(tool.class_name, tool.params) + tool.metadata = {} if tool.class_name in ["KnowledgeBaseSearchTool", "AnalyzeTextFileTool", "AnalyzeImageTool", "AnalyzeAudioTool", "AnalyzeVideoTool", "DataMateSearchTool"]: tool.metadata = {} if tool.class_name == "IndependentAidpSearchTool": @@ -515,6 +532,7 @@ async def export_agent_by_agent_id( is_main_agent=agent_info.get("is_main_agent", True), provide_run_summary=agent_info["provide_run_summary"], allow_chat_metadata=agent_info.get("allow_chat_metadata", False), + enable_protocol_repair_retry=agent_info.get("enable_protocol_repair_retry") is True, verification_config=agent_info.get("verification_config"), context_policy=agent_info.get("context_policy"), model_params_override=agent_info.get("model_params_override"), @@ -618,23 +636,14 @@ async def import_agent_by_agent_id( db_tool_info: dict | None = db_all_tool_info_dict.get( f"{tool.class_name}&{tool.source}", None) - if db_tool_info is None: - raise ValueError( - f"Cannot find tool {tool.class_name} in {tool.source}.") - - db_tool_info_params = db_tool_info["params"] - db_tool_info_params_name_set = set( - [param_info["name"] for param_info in db_tool_info_params]) - - for tool_param_name in tool.params: - if tool_param_name not in db_tool_info_params_name_set: - raise ValueError( - f"Parameter {tool_param_name} in tool {tool.class_name} from {tool.source} cannot be found.") + portable_params = validate_import_tool_params( + tool.class_name, tool.source, tool.params, db_tool_info, + ) tool_list.append(ToolInstanceInfoRequest(tool_id=db_tool_info['tool_id'], agent_id=-1, enabled=True, - params=tool.params)) + params=portable_params)) # check the validity of the agent parameters if import_agent_info.max_steps <= 0: raise ValueError( @@ -692,6 +701,7 @@ async def import_agent_by_agent_id( "is_main_agent": getattr(import_agent_info, "is_main_agent", True), "provide_run_summary": import_agent_info.provide_run_summary, "allow_chat_metadata": import_agent_info.allow_chat_metadata, + "enable_protocol_repair_retry": getattr(import_agent_info, "enable_protocol_repair_retry", False), "verification_config": getattr(import_agent_info, "verification_config", None), "context_policy": getattr(import_agent_info, "context_policy", None), "model_params_override": getattr(import_agent_info, "model_params_override", None), @@ -755,7 +765,9 @@ async def clear_agent_new_mark_impl(agent_id: int, tenant_id: str, user_id: str) return rowcount -async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: +async def list_all_agent_info_impl( + tenant_id: str, user_id: str, agent_ids: Optional[list[int]] = None +) -> list[dict]: """ list all agent info @@ -786,7 +798,11 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: ) user_group_ids = set() - agent_list = query_all_agent_info_by_tenant_id(tenant_id=tenant_id) + agent_list = ( + query_agent_info_by_ids(tenant_id, agent_ids) + if agent_ids is not None + else query_all_agent_info_by_tenant_id(tenant_id=tenant_id) + ) # Get all agent IDs that are registered as A2A Server agents a2a_server_agent_ids = get_server_agent_ids(tenant_id) @@ -795,6 +811,8 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: enriched_agents: list[dict] = [] for agent in agent_list: + if agent.get("agent_origin") == "SYSTEM" or agent.get("system_key"): + continue if not agent["enabled"]: continue @@ -837,6 +855,22 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: # mark later ones as unavailable due to duplication. apply_duplicate_name_availability_rules(enriched_agents) + agent_tag_values: Dict[str, List[str]] = {} + agent_ids = [ + str(entry["raw_agent"]["agent_id"]) + for entry in enriched_agents + if entry["raw_agent"].get("agent_id") is not None + ] + if agent_ids: + try: + agent_tag_values = ( + TagManagementDB.list_resource_assignment_display_values_by_ids( + tenant_id, "agent", agent_ids + ) + ) + except Exception as error: + logger.warning("Failed to load agent tags: %s", error) + simple_agent_list: list[dict] = [] for entry in enriched_agents: agent = entry["raw_agent"] @@ -860,7 +894,11 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: "name": agent["name"] if agent["name"] else agent["display_name"], "display_name": agent["display_name"] if agent["display_name"] else agent["name"], "description": agent["description"], + "icon_url": agent.get("icon_url"), "author": agent.get("author"), + "created_by": agent.get("created_by"), + "create_time": agent.get("create_time"), + "tags": agent_tag_values.get(str(agent["agent_id"]), []), "model_ids": model_ids, "model_names": model_names, "model_name": first_model_name, @@ -873,6 +911,8 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: "current_version_no": agent.get("current_version_no"), "is_a2a_server": agent["agent_id"] in a2a_server_agent_ids, "allow_chat_metadata": bool(agent.get("allow_chat_metadata", False)), + "model_params_override": agent.get("model_params_override"), + "enable_protocol_repair_retry": agent.get("enable_protocol_repair_retry") is True, }) return simple_agent_list @@ -881,6 +921,295 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: raise ValueError(f"Failed to query all agent info: {str(e)}") +async def list_agent_page_impl( + tenant_id: str, + user_id: str, + caller_tenant_id: Optional[str] = None, + permission: Optional[str] = None, + tag: Optional[str] = None, + search: Optional[str] = None, + page: int = 1, + page_size: int = 20, + additional_tenant_id: Optional[str] = None, + created_by: Optional[str] = None, + created_by_not: Optional[str] = None, + tag_predicates: Optional[list] = None, + search_tag_predicates: Optional[list] = None, + include_repository_info: bool = False, +) -> Dict[str, Any]: + """List visible agents with server-side filters and pagination.""" + if created_by and created_by_not: + raise ValueError("created_by and created_by_not cannot be used together") + + user_tenant_record = get_user_tenant_by_user_id(user_id) or {} + user_role = str(user_tenant_record.get("user_role") or "").upper() + can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + user_group_ids: set[int] = set() + if not can_edit_all: + try: + user_group_ids = set(query_group_ids_by_user(user_id) or []) + except Exception as error: + logger.warning("Failed to query user group ids for filtering: %s", error) + + agents: list[dict] = [] + duplicate_reasons: dict[tuple[str, int], list[str]] = {} + tenant_ids = [tenant_id] + if additional_tenant_id: + tenant_ids.append(additional_tenant_id) + for scope_tenant_id in tenant_ids: + candidates = query_agent_list_candidates_by_tenant_id( + scope_tenant_id, include_description=bool(search and search.strip()) + ) + visible: list[dict] = [] + for candidate in candidates: + if candidate.get("enabled") is False: + continue + if not can_edit_all: + agent_group_ids = set(convert_string_to_list(candidate.get("group_ids"))) + is_creator = str(candidate.get("created_by")) == str(user_id) + if not is_creator and ( + not user_group_ids.intersection(agent_group_ids) + or candidate.get("ingroup_permission") == PERMISSION_PRIVATE + ): + continue + candidate.setdefault("tenant_id", scope_tenant_id) + if "permission" not in candidate: + candidate["permission"] = resolve_agent_list_permission( + user_role=user_role, + agent=candidate, + user_id=user_id, + can_edit_all=can_edit_all, + ) + visible.append(candidate) + + duplicate_entries = [ + {"raw_agent": candidate, "unavailable_reasons": []} + for candidate in visible + ] + apply_duplicate_name_availability_rules(duplicate_entries) + for entry in duplicate_entries: + reasons = entry["unavailable_reasons"] + if reasons: + duplicate_reasons[(scope_tenant_id, int(entry["raw_agent"]["agent_id"]))] = reasons + + if tag and tag.strip(): + missing_tags = [candidate for candidate in visible if "tags" not in candidate] + try: + values = ( + TagManagementDB.list_resource_assignment_display_values_by_ids( + scope_tenant_id, + "agent", + [str(candidate["agent_id"]) for candidate in missing_tags], + ) + if missing_tags else {} + ) + except Exception as error: + logger.warning("Failed to load agent tags: %s", error) + values = {} + for candidate in missing_tags: + candidate.setdefault("tags", values.get(str(candidate["agent_id"]), [])) + agents.extend(visible) + + agent_ids = [str(agent["agent_id"]) for agent in agents if agent.get("agent_id") is not None] + if tag_predicates: + matched_ids = set(TagManagementDB.filter_authorized_resource_ids( + tenant_id, "agent", agent_ids, tag_predicates + )) + agents = [agent for agent in agents if str(agent.get("agent_id")) in matched_ids] + agent_ids = [str(agent["agent_id"]) for agent in agents if agent.get("agent_id") is not None] + + search_tag_ids = set() + if search_tag_predicates: + search_tag_ids = set(TagManagementDB.filter_authorized_resource_ids( + tenant_id, "agent", agent_ids, search_tag_predicates + )) + + creator_count = sum( + str(agent.get("created_by")) == str(user_id) for agent in agents + ) + creator_counts = { + "all": len(agents), + "created": creator_count, + "others": len(agents) - creator_count, + } + + if created_by: + agents = [agent for agent in agents if str(agent.get("created_by")) == created_by] + if created_by_not: + agents = [agent for agent in agents if str(agent.get("created_by")) != created_by_not] + + if permission: + normalized_permission = permission.strip().upper() + if normalized_permission not in {"EDIT", "READ_ONLY"}: + raise ValueError("permission must be EDIT or READ_ONLY") + agents = [ + agent + for agent in agents + if agent.get("permission") == normalized_permission + ] + + if tag and tag.strip(): + normalized_tag = tag.strip().casefold() + agents = [ + agent + for agent in agents + if any( + str(agent_tag).casefold() == normalized_tag + for agent_tag in agent.get("tags", []) + ) + ] + + if search and search.strip(): + normalized_search = search.strip().casefold() + agents = [ + agent + for agent in agents + if str(agent.get("agent_id")) in search_tag_ids or any( + normalized_search in str(agent.get(field) or "").casefold() + for field in ("name", "display_name", "description") + ) + ] + + total = len(agents) + offset = (page - 1) * page_size + paged_candidates = agents[offset:offset + page_size] + enriched_by_key: dict[tuple[str, int], dict] = {} + for scope_tenant_id in tenant_ids: + page_ids = [ + int(agent["agent_id"]) + for agent in paged_candidates + if agent["tenant_id"] == scope_tenant_id + ] + if not page_ids: + continue + enriched = await list_all_agent_info_impl( + tenant_id=scope_tenant_id, user_id=user_id, agent_ids=page_ids + ) + enriched_by_key.update( + { + (scope_tenant_id, int(agent["agent_id"])): agent + for agent in enriched + if int(agent["agent_id"]) in page_ids + } + ) + + paged_scoped_agents: list[tuple[str, dict]] = [] + for candidate in paged_candidates: + key = (candidate["tenant_id"], int(candidate["agent_id"])) + enriched = enriched_by_key.get(key) + if enriched is None: + continue + agent = dict(enriched) + reasons = list( + dict.fromkeys( + [*agent.get("unavailable_reasons", []), *duplicate_reasons.get(key, [])] + ) + ) + agent["unavailable_reasons"] = reasons + agent["is_available"] = not reasons + paged_scoped_agents.append((candidate["tenant_id"], agent)) + + for scope_tenant_id in tenant_ids: + versioned_agents = [ + agent for scope, agent in paged_scoped_agents + if scope == scope_tenant_id + and (agent.get("current_version_no") or 0) > 0 + ] + if not versioned_agents: + continue + version_rows = batch_search_version_names( + [int(agent["agent_id"]) for agent in versioned_agents], + scope_tenant_id, + [int(agent["current_version_no"]) for agent in versioned_agents], + ) + version_by_key = { + (row["agent_id"], row["version_no"]): row for row in version_rows + } + for agent in versioned_agents: + version = version_by_key.get( + (int(agent["agent_id"]), int(agent["current_version_no"])), {} + ) + agent["version_label"] = version.get("version_name") + agent["version_create_time"] = version.get("create_time") + if include_repository_info: + for scope_tenant_id in tenant_ids: + scoped_agents = [ + agent for scope, agent in paged_scoped_agents + if scope == scope_tenant_id + ] + if not scoped_agents: + continue + scoped_agent_ids = [int(agent["agent_id"]) for agent in scoped_agents] + shared_records = list_agent_repository_by_agent_ids( + scoped_agent_ids, + statuses=(STATUS_SHARED,), + publisher_tenant_id=scope_tenant_id, + ) + publisher_records = [] + if scope_tenant_id == caller_tenant_id and user_role == "ADMIN": + publisher_records = list_agent_repository_by_agent_ids( + scoped_agent_ids, + statuses=(STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED), + publisher_tenant_id=scope_tenant_id, + ) + elif scope_tenant_id == caller_tenant_id and user_role == "DEV": + publisher_records = list_agent_repository_by_agent_ids( + scoped_agent_ids, + statuses=(STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED), + publisher_tenant_id=scope_tenant_id, + publisher_user_id=user_id, + ) + records_by_id = { + int(record["agent_repository_id"]): (record, False) + for record in shared_records + if record["status"] == STATUS_SHARED + } + records_by_id.update({ + int(record["agent_repository_id"]): (record, True) + for record in publisher_records + }) + repository_by_agent_id: dict[int, list[dict]] = {} + for record, is_publisher in records_by_id.values(): + created_at = record.get("create_time") + repository_by_agent_id.setdefault(int(record["agent_id"]), []).append( + { + "agent_repository_id": record["agent_repository_id"], + "status": record["status"], + "version_no": record["version_no"], + "version_label": record.get("version_name"), + "create_time": ( + created_at.isoformat() + if hasattr(created_at, "isoformat") + else created_at + ), + "content": record.get("content") if is_publisher else None, + } + ) + for records in repository_by_agent_id.values(): + records.sort( + key=lambda item: ( + str(item["create_time"] or ""), + int(item["agent_repository_id"]), + ), + reverse=True, + ) + for agent in scoped_agents: + agent["repository_info"] = repository_by_agent_id.get( + int(agent["agent_id"]), [] + ) + paged_agents = [agent for _, agent in paged_scoped_agents] + return { + "items": paged_agents, + "creator_counts": creator_counts, + "pagination": { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": (total + page_size - 1) // page_size if total else 0, + }, + } + + def insert_related_agent_impl(parent_agent_id, child_agent_id, tenant_id): @@ -1090,6 +1419,8 @@ async def export_agent_with_skills_impl( - ExportAndImportDataFormat as a plain dict when the agent has no skills """ user_id, tenant_id, _ = get_current_user_info(authorization) + if is_system_agent(agent_id, tenant_id) is True: + raise ForbiddenError("System Agent cannot be exported") skill_zip_entries = collect_skill_zip_entries( agent_id=agent_id, tenant_id=tenant_id, version_no=version_no @@ -1145,6 +1476,18 @@ async def import_agent_with_skills_impl( """ user_id, tenant_id, _ = get_current_user_info(authorization) + # Validate every agent before creating any dependency skills. + catalog = { + (tool["class_name"], tool["source"]): tool + for tool in query_all_tools(tenant_id=tenant_id) + } + for agent in agent_info.agent_info.values(): + for tool in agent.tools: + validate_import_tool_params( + tool.class_name, tool.source, tool.params, + catalog.get((tool.class_name, tool.source)), + ) + skill_name_to_zip_base64 = { entry.skill_name: entry.skill_zip_base64 for entry in skills} diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 3ca6ec8471..92adbda92d 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -1,4 +1,5 @@ import asyncio +from copy import deepcopy from http import HTTPStatus import json import logging @@ -30,6 +31,7 @@ from consts.const import ( DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE, + ENABLE_AGENT_WORKBENCH, LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, @@ -46,6 +48,8 @@ RuntimeMetadataVersionConflict, RuntimeCapacityExceededError, RuntimeQueueTimeoutError, + ValidationError, + WorkbenchError, ) from consts.error_code import ErrorCode, RuntimeMetadataValidationCode from nexent.core.utils.observer import ProcessType @@ -84,6 +88,8 @@ update_conversation_knowledge_scope_service, update_message_status, update_unit_status, # noqa: F401 - retained as a compatibility re-export + update_conversation_workbench_config_service, + update_conversation_workbench_and_metadata_service, ) from services.memory_config_service import build_memory_context from services.memory_backend_adapter import _build_ingestion_event_service @@ -131,7 +137,6 @@ _external_memory_ingest_tasks: set[asyncio.Task[None]] = set() _fa_extraction_tasks: set[asyncio.Task[None]] = set() - def _unregister_agent_run_after_execution( conversation_id: int | str, user_id: str, @@ -245,7 +250,7 @@ async def _consume_agent_stream_producer( ) if not channel.is_completed: try: - await channel.publish(_safe_agent_stream_error_chunk()) + await channel.publish(_safe_agent_stream_error_chunk(stream_exc)) except Exception: logger.exception( "Failed to publish producer error conversation=%s", @@ -784,8 +789,8 @@ async def _iter_run_chunks(): stream_completed_normally = True except Exception as run_exc: logger.error("Agent run error: %r", run_exc, exc_info=True) - await channel.publish(_safe_agent_stream_error_chunk()) - yield _safe_agent_stream_error_chunk() + await channel.publish(_safe_agent_stream_error_chunk(run_exc)) + yield _safe_agent_stream_error_chunk(run_exc) finally: if not cancel_poll_task.done(): cancel_poll_task.cancel() @@ -803,6 +808,7 @@ async def _iter_run_chunks(): ) outcome = getattr(agent_run_info, "attempt_outcome", None) if outcome in {"failed", "stopped"}: + # A typed terminal model error is delivered as a normal observer # ``error`` chunk, so the async iterator can finish normally while # the worker outcome still authoritatively marks the run failed. @@ -1105,6 +1111,53 @@ def _agent_run_identifier(agent_request: AgentRequest) -> int | str | None: return agent_request.conversation_id +def apply_workbench_runtime_plan( + agent_request: AgentRequest, canonical_workbench, resolved_plan, tenant_id: str +) -> None: + """Attach a validated Workbench plan to one in-memory run request.""" + from services.knowledge_scope_service import snapshot_runtime_knowledge_tree + from services.workbench_service import attach_runtime_knowledge_tree, runtime_skill_snapshot + + knowledge_tree = snapshot_runtime_knowledge_tree( + int(resolved_plan.root.identity.agent_id), + tenant_id, + int(resolved_plan.root.identity.version_no), + ) + if canonical_workbench.knowledge_scope is not None: + from services.runtime_knowledge_mount import mount_knowledge_records + + knowledge_tree = knowledge_tree[:1] + knowledge_tree[0]["tools"] = mount_knowledge_records( + knowledge_tree[0]["tools"], canonical_workbench.knowledge_scope, tenant_id + ) + agent_request.__dict__["_runtime_knowledge_tools"] = knowledge_tree[0]["tools"] + resolved_plan = attach_runtime_knowledge_tree(resolved_plan, knowledge_tree) + root_identity = resolved_plan.root.identity + agent_request.workbench = canonical_workbench + agent_request.agent_id = root_identity.agent_id + agent_request.version_no = root_identity.version_no + if resolved_plan.overlay.model_id is not None: + agent_request.model_id = resolved_plan.overlay.model_id + if resolved_plan.overlay.requested_output_tokens is not None: + agent_request.requested_output_tokens = ( + resolved_plan.overlay.requested_output_tokens + ) + agent_request.knowledge_scope = canonical_workbench.knowledge_scope + agent_request.__dict__["_runtime_skill_snapshot"] = runtime_skill_snapshot(resolved_plan) + agent_request.__dict__["_runtime_mount_plan"] = resolved_plan + agent_request.__dict__["_runtime_root_identity"] = { + "agent_id": root_identity.agent_id, + "version_no": root_identity.version_no, + "runtime_ref": root_identity.runtime_ref, + "invocation_name": root_identity.invocation_name, + "display_name": root_identity.display_name, + "origin": root_identity.origin, + } + agent_request.__dict__["_runtime_generation_config"] = ( + canonical_workbench.generation_config.model_dump(mode="json") + ) + + # Helper function for run_agent_stream, used to prepare context for an agent run async def prepare_agent_run( agent_request: AgentRequest, @@ -1134,6 +1187,8 @@ async def prepare_agent_run( "is_debug": agent_request.is_debug, "override_version_no": agent_request.version_no, "override_model_id": agent_request.model_id, + "reasoning_effort": agent_request.reasoning_effort, + "reasoning_budget_tokens": agent_request.reasoning_budget_tokens, "requested_output_tokens": agent_request.requested_output_tokens, "tool_params": agent_request.tool_params, "conversation_id": agent_request.conversation_id, @@ -1145,11 +1200,39 @@ async def prepare_agent_run( ) if isinstance(runtime_knowledge_context, dict): create_run_kwargs["runtime_knowledge_context"] = runtime_knowledge_context + runtime_skill_snapshot = getattr(agent_request, "_runtime_skill_snapshot", None) + runtime_knowledge_tools = getattr(agent_request, "_runtime_knowledge_tools", None) + if runtime_knowledge_tools is not None: + create_run_kwargs["runtime_knowledge_tools"] = runtime_knowledge_tools + if runtime_skill_snapshot is not None: + create_run_kwargs["runtime_skill_snapshot"] = runtime_skill_snapshot + runtime_generation_config = getattr(agent_request, "_runtime_generation_config", None) + if runtime_generation_config is None and agent_request.generation_config is not None: + runtime_generation_config = agent_request.generation_config.model_dump(mode="json") + if runtime_generation_config is not None: + create_run_kwargs["runtime_generation_config"] = runtime_generation_config + runtime_mount_plan = getattr(agent_request, "_runtime_mount_plan", None) + if runtime_mount_plan is not None: + create_run_kwargs["runtime_sub_agent_mounts"] = [ + { + "agent_id": child.agent_id, + "version_no": child.version_no, + "runtime_ref": child.runtime_ref, + "invocation_name": child.invocation_name, + "display_name": child.display_name, + } + for child in runtime_mount_plan.child_mounts + ] if not agent_request.enable_automation_tool: create_run_kwargs["enable_automation_tool"] = False agent_run_info = await create_agent_run_info( **create_run_kwargs, ) + if runtime_mount_plan is not None: + from services.workbench_service import compile_runtime_mount_plan + + executable_tree = compile_runtime_mount_plan(runtime_mount_plan, agent_run_info.agent_config) + agent_run_info.agent_config = executable_tree.root.agent_config agent_run_info.runtime_metadata = dict( getattr(agent_request, "_runtime_metadata_snapshot", {}) or {} ) @@ -1334,12 +1417,16 @@ async def generate_stream( agent_run_info=agent_run_info, ) raise - except MemoryPreparationException: + except MemoryPreparationException as prep_error: if not enable_memory: # No-memory path has no fallback; surface the failure cleanly. - logger.error("Agent run error without memory: %r", None, exc_info=True) - await channel.publish(_safe_agent_stream_error_chunk()) - yield _safe_agent_stream_error_chunk() + logger.error( + "Agent run preparation failed without memory: %s", type(prep_error.__cause__ or prep_error).__name__, exc_info=True + ) + error_chunk = _safe_agent_stream_error_chunk() + if channel is not None: + await channel.publish(error_chunk) + yield error_chunk return try: @@ -1363,8 +1450,10 @@ async def generate_stream( run_exc, exc_info=True, ) - await channel.publish(_safe_agent_stream_error_chunk()) - yield _safe_agent_stream_error_chunk() + error_chunk = _safe_agent_stream_error_chunk(run_exc) + if channel is not None: + await channel.publish(error_chunk) + yield error_chunk return except Exception as stream_exc: logger.error( @@ -1372,8 +1461,10 @@ async def generate_stream( stream_exc, exc_info=True, ) - await channel.publish(_safe_agent_stream_error_chunk()) - yield _safe_agent_stream_error_chunk() + error_chunk = _safe_agent_stream_error_chunk(stream_exc) + if channel is not None: + await channel.publish(error_chunk) + yield error_chunk return finally: if cancel_poll_task and not cancel_poll_task.done(): @@ -1489,6 +1580,9 @@ async def run_agent_stream( Args: resume: If True, check for existing streaming message and continue from where it left off """ + if agent_request.entrypoint == "workbench" and not ENABLE_AGENT_WORKBENCH: + raise WorkbenchError("WORKBENCH_DISABLED", status_code=404) + resolved_user_id, resolved_tenant_id, language = _resolve_user_tenant_language( authorization=authorization, http_request=http_request, @@ -1520,6 +1614,47 @@ async def run_agent_stream( raise ForbiddenError( "Conversation is not accessible to the current identity" ) + if not ENABLE_AGENT_WORKBENCH and conversation.get("workbench_config"): + raise WorkbenchError("WORKBENCH_DISABLED", status_code=404) + if not resume: + is_workbench_conversation = isinstance(conversation.get("workbench_config"), dict) + if is_workbench_conversation != (agent_request.entrypoint == "workbench"): + raise ForbiddenError("Conversation belongs to a different chat entrypoint") + + canonical_workbench = None + if agent_request.entrypoint == "workbench" and not resume: + from services.workbench_service import assert_workbench_version, resolve_workbench_config + + requested_workbench = agent_request.workbench + if conversation is not None: + assert_workbench_version(conversation, agent_request.expected_workbench_config_version) + if requested_workbench is None and isinstance(conversation.get("workbench_config"), dict): + from consts.model import WorkbenchSessionConfig + + requested_workbench = WorkbenchSessionConfig.model_validate(conversation["workbench_config"]) + if agent_request.metadata is not None and agent_request.expected_metadata_version is not None: + current_metadata_version = int(conversation.get("runtime_metadata_version") or 0) + if agent_request.expected_metadata_version != current_metadata_version: + raise AppException( + ErrorCode.CHAT_METADATA_VERSION_CONFLICT, + details={"current_version": current_metadata_version}, + ) + if requested_workbench is None: + raise ValidationError("Workbench configuration is required") + canonical_workbench, resolved_tree = resolve_workbench_config( + requested_workbench, + tenant_id=resolved_tenant_id, + is_debug=bool(agent_request.is_debug), + user_id=resolved_user_id, + ) + apply_workbench_runtime_plan( + agent_request, canonical_workbench, resolved_tree, resolved_tenant_id + ) + if conversation is not None: + assert_workbench_version( + conversation, + agent_request.expected_workbench_config_version, + ) metadata_supplied = "metadata" in agent_request.model_fields_set metadata_update_requested = metadata_supplied and agent_request.metadata is not None @@ -1559,7 +1694,11 @@ async def run_agent_stream( ) else: request_scope = None - stored_scope = conversation.get("knowledge_scope") if conversation else None + stored_scope = ( + conversation.get("knowledge_scope") + if conversation and canonical_workbench is None + else None + ) if not isinstance(stored_scope, dict): stored_scope = None source_scope = request_scope @@ -1570,6 +1709,18 @@ async def run_agent_stream( if source_scope is not None and not resume: if agent_request.agent_id is None: raise ValueError("agent_id is required when knowledge_scope is set") + runtime_knowledge_tree = [ + deepcopy(dict(node)) + for node in getattr( + getattr(agent_request, "_runtime_mount_plan", None), + "knowledge_tree", + (), + ) + ] + resolve_scope_kwargs = {} + if runtime_knowledge_tree: + resolve_scope_kwargs["runtime_agent_tree"] = runtime_knowledge_tree + resolved_scope = resolve_knowledge_scope( scope=source_scope, agent_id=agent_request.agent_id, @@ -1578,6 +1729,7 @@ async def run_agent_stream( version_no=agent_request.version_no, is_debug=bool(agent_request.is_debug), request_tool_params=agent_request.tool_params, + **resolve_scope_kwargs, ) agent_request.tool_params = resolved_scope.tool_params agent_request.__dict__["_runtime_knowledge_context"] = { @@ -1624,6 +1776,10 @@ async def run_agent_stream( "agent_id": agent_request.agent_id, "chat_mode": "planning" if agent_request.enable_plan else "execution", } + if canonical_workbench is not None: + conversation_kwargs["workbench_config"] = canonical_workbench.model_dump( + mode="json" + ) if resolved_scope is not None: conversation_kwargs["knowledge_scope"] = resolved_scope.desired_scope if metadata_update_requested: @@ -1638,6 +1794,46 @@ async def run_agent_stream( ) if not resume: + joint_runtime_state = None + if ( + canonical_workbench is not None + and not is_new_conversation + and metadata_update_requested + and not agent_request.is_debug + ): + try: + joint_runtime_state = update_conversation_workbench_and_metadata_service( + conversation_id=agent_request.conversation_id, + user_id=resolved_user_id, + config=canonical_workbench.model_dump(mode="json"), + expected_config_version=agent_request.expected_workbench_config_version, + metadata=agent_request.metadata or {}, + expected_metadata_version=agent_request.expected_metadata_version, + ) + except RuntimeMetadataVersionConflict as exc: + raise AppException( + ErrorCode.CHAT_METADATA_VERSION_CONFLICT, + details={"current_version": exc.current_version}, + ) from exc + conversation = {**(conversation or {}), **joint_runtime_state} + elif canonical_workbench is not None and not is_new_conversation and not agent_request.is_debug: + updated = update_conversation_workbench_config_service( + conversation_id=agent_request.conversation_id, + user_id=resolved_user_id, + config=canonical_workbench.model_dump(mode="json"), + expected_version=agent_request.expected_workbench_config_version, + only_if_changed=True, + ) + conversation = {**(conversation or {}), **updated} + + if canonical_workbench is not None and not agent_request.is_debug: + workbench_state = ( + conversation_data if is_new_conversation else (conversation or {}) + ) + agent_request.__dict__["_workbench_config_version"] = int( + workbench_state.get("workbench_config_version") or 0 + ) + if agent_request.is_debug: metadata_snapshot = ( dict(agent_request.metadata or {}) if metadata_update_requested else {} @@ -1658,6 +1854,9 @@ async def run_agent_stream( ) or 0 ) + elif joint_runtime_state is not None: + metadata_snapshot = dict(joint_runtime_state["runtime_metadata"] or {}) + metadata_version = int(joint_runtime_state["runtime_metadata_version"]) elif not metadata_update_requested: metadata_snapshot = dict((conversation or {}).get("runtime_metadata") or {}) metadata_version = int( @@ -1705,6 +1904,7 @@ async def run_agent_stream( and not resume and not is_new_conversation and agent_request.conversation_id is not None + and agent_request.entrypoint != "workbench" ): update_conversation_knowledge_scope_service( conversation_id=agent_request.conversation_id, @@ -1719,6 +1919,7 @@ async def run_agent_stream( and not is_new_conversation and agent_request.conversation_id is not None and agent_request.agent_id is not None + and agent_request.entrypoint != "workbench" ): update_conversation_agent_id_service( conversation_id=agent_request.conversation_id, @@ -2092,6 +2293,16 @@ async def stream_with_agent_context(): scope_event = getattr( agent_request, "_resolved_knowledge_scope_event", None ) + if canonical_workbench is not None: + yield "data: " + json.dumps({ + "type": "workbench_config_resolved", + "content": { + "config_version": getattr(agent_request, "_workbench_config_version", 0), + "schema_version": 3, + "mode": canonical_workbench.mode, + "agent_mounts": [mount.model_dump(mode="json") for mount in canonical_workbench.agent_mounts], + }, + }, ensure_ascii=False) + "\n\n" if scope_event is not None: yield ( "data: " @@ -2117,7 +2328,7 @@ async def stream_with_agent_context(): stream_exc, exc_info=True, ) - yield _safe_agent_stream_error_chunk() + yield _safe_agent_stream_error_chunk(stream_exc) finally: if channel is None and not execution.future.done(): deferred_run.cancel() @@ -2142,6 +2353,11 @@ async def stream_with_agent_context(): runtime_metadata_version = getattr(agent_request, "_runtime_metadata_version", None) if runtime_metadata_version is not None: headers["X-Runtime-Metadata-Version"] = str(runtime_metadata_version) + workbench_config_version = getattr( + agent_request, "_workbench_config_version", None + ) + if workbench_config_version is not None: + headers["X-Workbench-Config-Version"] = str(workbench_config_version) return StreamingResponse( stream_with_agent_context(), diff --git a/backend/management/services/agent/run_context.py b/backend/management/services/agent/run_context.py index 575122a023..2a2c9010d7 100644 --- a/backend/management/services/agent/run_context.py +++ b/backend/management/services/agent/run_context.py @@ -4,9 +4,9 @@ from typing import Any from nexent.monitor import AgentRunMetadata - from services.memory_config_service import build_memory_context from utils.monitoring import monitoring_manager +from utils.monitoring_identity import resolve_monitoring_user_email @dataclass(frozen=True) @@ -23,6 +23,7 @@ def build_agent_run_context(request, user_id: str, tenant_id: str, language: str agent_id=request.agent_id, conversation_id=request.conversation_id, user_id=user_id, + user_email=resolve_monitoring_user_email(user_id, tenant_id), tenant_id=tenant_id, query=request.query, is_debug=request.is_debug, diff --git a/backend/management/services/agent/runtime_sub_agent_adapter.py b/backend/management/services/agent/runtime_sub_agent_adapter.py new file mode 100644 index 0000000000..c1e8e5d54e --- /dev/null +++ b/backend/management/services/agent/runtime_sub_agent_adapter.py @@ -0,0 +1,226 @@ +"""Unified adapters for platform-owned, specialized runtime Agents.""" + +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Protocol + +from nexent.core.agents.agent_model import AgentRunInfo + +from consts.exceptions import RuntimeSubAgentError +from consts.model import NL2AgentRunRequest, NL2SkillRunRequest +from permissions.rbac import has_permission +from services.agent_draft_permission_service import require_agent_draft_edit + +NL2SKILL_ADAPTER_KEY = "builtin:nl2skill" +NL2AGENT_ADAPTER_KEY = "builtin:nl2agent" + + +def _validate_run_minio_files(minio_files, user_id: str, tenant_id: str) -> None: + """Load the canonical attachment policy lazily to avoid import cycles.""" + from agents.create_agent_info import ( + _validate_run_minio_files as validate_run_minio_files, + ) + + validate_run_minio_files(minio_files, user_id, tenant_id) + + +async def _build_nl2skill_run_info(**kwargs) -> AgentRunInfo: + """Load the NL2Skill builder only after adapter authorization succeeds.""" + from services.nl2skill_service import build_nl2skill_run_info + + return await build_nl2skill_run_info(**kwargs) + + +def _create_nl2skill_stream(**kwargs) -> AsyncIterator[str]: + """Load the existing NL2Skill stream factory at the runtime boundary.""" + from services.nl2skill_service import create_nl2skill_stream + + return create_nl2skill_stream(**kwargs) + + +async def _build_nl2agent_run_info(**kwargs) -> AgentRunInfo: + """Load the NL2Agent builder only after adapter authorization succeeds.""" + from services.nl2agent_service import build_nl2agent_run_info + + return await build_nl2agent_run_info(**kwargs) + + +def _create_nl2agent_stream(**kwargs) -> AsyncIterator[str]: + """Load the existing NL2Agent stream factory at the runtime boundary.""" + from services.nl2agent_service import create_nl2agent_stream + + return create_nl2agent_stream(**kwargs) + + +def _collect_request_files( + request: NL2SkillRunRequest | NL2AgentRunRequest, +) -> list[dict] | None: + """Collect current and historical attachment references for authorization.""" + files = [ + item for item in (request.minio_files or []) if isinstance(item, dict) + ] + for history_item in request.history or []: + files.extend( + item + for item in (history_item.minio_files or []) + if isinstance(item, dict) + ) + return files or None + + +@dataclass(frozen=True) +class RuntimeSubAgentContext: + """Request-scoped inputs shared by specialized runtime adapters.""" + + request: NL2SkillRunRequest | NL2AgentRunRequest + tenant_id: str + user_id: str + user_role: str + language: str + authorization: str | None = None + + +class RuntimeSubAgentAdapter(Protocol): + """Contract implemented by a platform-owned specialized Agent runtime.""" + + key: str + + async def authorize(self, context: RuntimeSubAgentContext) -> None: ... + + async def build_run_info( + self, + context: RuntimeSubAgentContext, + ) -> AgentRunInfo: ... + + async def stream( + self, + context: RuntimeSubAgentContext, + ) -> AsyncIterator[str]: ... + + +def _validate_context(context: RuntimeSubAgentContext, request_type: type) -> None: + if not context.tenant_id or not context.tenant_id.strip(): + raise RuntimeSubAgentError("runtime_context_invalid") + if not context.user_id or not context.user_id.strip(): + raise RuntimeSubAgentError("runtime_context_invalid") + if not isinstance(context.request, request_type): + raise RuntimeSubAgentError( + "runtime_adapter_request_mismatch", + message=( + f"Adapter requires {request_type.__name__}, " + f"got {type(context.request).__name__}" + ), + ) + + +def _authorize_creation( + context: RuntimeSubAgentContext, + *, + permission: str, +) -> None: + if not has_permission(context.user_role, permission): + raise RuntimeSubAgentError("creation_forbidden") + try: + _validate_run_minio_files( + _collect_request_files(context.request), + context.user_id, + context.tenant_id, + ) + except Exception as exc: + raise RuntimeSubAgentError("attachment_forbidden") from exc + + +class Nl2SkillRuntimeAdapter: + """Preserve the NL2Skill classifier and structured file event stream.""" + + key = NL2SKILL_ADAPTER_KEY + + async def authorize(self, context: RuntimeSubAgentContext) -> None: + _validate_context(context, NL2SkillRunRequest) + _authorize_creation(context, permission="skill:create") + + async def build_run_info( + self, + context: RuntimeSubAgentContext, + ) -> AgentRunInfo: + await self.authorize(context) + return await _build_nl2skill_run_info( + request=context.request, + tenant_id=context.tenant_id, + language=context.language, + ) + + async def stream( + self, + context: RuntimeSubAgentContext, + ) -> AsyncIterator[str]: + await self.authorize(context) + return _create_nl2skill_stream( + request=context.request, + tenant_id=context.tenant_id, + language=context.language, + ) + + +class Nl2AgentRuntimeAdapter: + """Preserve NL2Agent draft authorization, cards, and boundary observer.""" + + key = NL2AGENT_ADAPTER_KEY + + async def authorize(self, context: RuntimeSubAgentContext) -> None: + _validate_context(context, NL2AgentRunRequest) + _authorize_creation(context, permission="agent:create") + try: + require_agent_draft_edit( + agent_id=context.request.agent_id, + tenant_id=context.tenant_id, + user_id=context.user_id, + ) + except Exception as exc: + raise RuntimeSubAgentError("draft_forbidden") from exc + + async def build_run_info( + self, + context: RuntimeSubAgentContext, + ) -> AgentRunInfo: + await self.authorize(context) + return await _build_nl2agent_run_info( + request=context.request, + tenant_id=context.tenant_id, + language=context.language, + authorization=context.authorization, + ) + + async def stream( + self, + context: RuntimeSubAgentContext, + ) -> AsyncIterator[str]: + await self.authorize(context) + return _create_nl2agent_stream( + request=context.request, + tenant_id=context.tenant_id, + language=context.language, + authorization=context.authorization, + ) + + +_ADAPTERS: dict[str, RuntimeSubAgentAdapter] = { + NL2SKILL_ADAPTER_KEY: Nl2SkillRuntimeAdapter(), + NL2AGENT_ADAPTER_KEY: Nl2AgentRuntimeAdapter(), +} + + +def get_runtime_sub_agent_adapter(key: str) -> RuntimeSubAgentAdapter: + """Resolve a registered specialized runtime by its stable key.""" + try: + return _ADAPTERS[key] + except KeyError as exc: + raise RuntimeSubAgentError( + "runtime_adapter_not_found", + message=f"Unknown runtime sub-Agent adapter: {key}", + ) from exc + + +def list_runtime_sub_agent_adapters() -> list[str]: + """Return stable adapter keys for diagnostics and Workbench discovery.""" + return sorted(_ADAPTERS) diff --git a/backend/management/services/agent/service.py b/backend/management/services/agent/service.py index 4118f6b646..19c7ff2bee 100644 --- a/backend/management/services/agent/service.py +++ b/backend/management/services/agent/service.py @@ -1,9 +1,8 @@ import asyncio -import imghdr -import io import logging from collections import deque from typing import Optional +from uuid import uuid4 from fastapi import Header from nexent.core.concurrency import run_blocking @@ -17,6 +16,7 @@ from consts.exceptions import ( AppException, ForbiddenError, + TenantResourceLimitError, ) from consts.error_code import ErrorCode from consts.agent_unavailable_reasons import AgentUnavailableReason @@ -37,11 +37,11 @@ from database.agent_db import ( batch_search_agent_display_names, create_agent, + is_system_agent, query_all_agent_info_by_tenant_id, query_sub_agent_relations, query_sub_agents_id_list, search_agent_info_by_agent_id, - search_blank_sub_agent_by_main_agent_id, update_agent, update_agent_icon, update_related_agents, @@ -59,10 +59,12 @@ search_tools_for_sub_agent, ) from database import skill_db -from database.attachment_db import ( - get_file_stream, +from management.services.agent.icon_storage import ( + ICON_MAX_BYTES, + read_icon_image, + upload_icon_image, + validate_icon_image, ) -from database.client import minio_client from management.services.skill.service import SkillService from database.agent_version_db import ( query_current_version_no, @@ -84,19 +86,16 @@ ) from utils.auth_utils import get_current_user_info from utils.config_utils import tenant_config_manager +from services.agent_reasoning_service import ( + snapshot_agent_reasoning_config as build_reasoning_snapshot, +) # Monitoring utilities: bind Agent metadata once at the request boundary. # Import monitoring utilities logger = logging.getLogger(__name__) -AGENT_ICON_MAX_BYTES = 2 * 1024 * 1024 -AGENT_ICON_CONTENT_TYPES = { - "gif": "image/gif", - "jpeg": "image/jpeg", - "png": "image/png", - "webp": "image/webp", -} +AGENT_ICON_MAX_BYTES = ICON_MAX_BYTES _channel_cleanup_tasks: set[asyncio.Task[None]] = set() _agent_stream_producer_tasks: set[asyncio.Task[None]] = set() @@ -117,6 +116,7 @@ load_default_agents_json_file, clear_agent_new_mark_impl, list_all_agent_info_impl, + list_agent_page_impl, insert_related_agent_impl, get_agent_id_by_name, get_agent_by_name_impl, @@ -166,6 +166,7 @@ "load_default_agents_json_file", "clear_agent_new_mark_impl", "list_all_agent_info_impl", + "list_agent_page_impl", "insert_related_agent_impl", "get_agent_id_by_name", "get_agent_by_name_impl", @@ -201,11 +202,6 @@ def _agent_icon_object_name(agent_id: int, tenant_id: str) -> str: return f"agent-icons/{tenant_id}/{agent_id}/icon" -def _detect_agent_icon_content_type(content: bytes) -> str | None: - image_type = imghdr.what(None, content) - return AGENT_ICON_CONTENT_TYPES.get(image_type) - - async def upload_agent_icon_impl( agent_id: int, content: bytes, @@ -213,26 +209,18 @@ async def upload_agent_icon_impl( user_id: str, ) -> dict: """Validate, store, and attach a user-supplied image to an editable agent.""" - if not content: - raise ValueError("Agent icon file is empty") - if len(content) > AGENT_ICON_MAX_BYTES: - raise ValueError("Agent icon must not exceed 2 MB") - - content_type = _detect_agent_icon_content_type(content) - if content_type is None: - raise ValueError("Agent icon must be a PNG, JPEG, GIF, or WebP image") - + if is_system_agent(agent_id, tenant_id) is True: + raise ForbiddenError("System Agent is managed by the platform") + validate_icon_image(content) agent = await get_agent_info_impl(agent_id, tenant_id, user_id=user_id) if agent.get("permission") != "EDIT": raise ForbiddenError("You do not have permission to edit this agent") owner_tenant_id = agent.get("tenant_id") or tenant_id object_name = _agent_icon_object_name(agent_id, owner_tenant_id) - success, error = minio_client.upload_fileobj(io.BytesIO(content), object_name) - if not success: - raise ValueError(f"Failed to upload agent icon: {error}") + content_type = upload_icon_image(content, object_name) - icon_url = f"/api/agent/{agent_id}/icon" + icon_url = f"/api/agent/{agent_id}/icon?v={uuid4().hex}" update_agent_icon( agent_id=agent_id, tenant_id=owner_tenant_id, @@ -251,15 +239,7 @@ async def get_agent_icon_impl( raise FileNotFoundError("Agent icon not found") owner_tenant_id = agent.get("tenant_id") or tenant_id - stream = get_file_stream(_agent_icon_object_name(agent_id, owner_tenant_id)) - if stream is None: - raise FileNotFoundError("Agent icon not found") - - content = stream.read() - content_type = _detect_agent_icon_content_type(content) - if content_type is None: - raise FileNotFoundError("Agent icon is invalid") - return content, content_type + return read_icon_image(_agent_icon_object_name(agent_id, owner_tenant_id)) async def check_agent_name_conflict_batch_impl( @@ -388,29 +368,22 @@ def get_enable_tool_id_by_agent_id(agent_id: int, tenant_id: str): return list(enable_tool_id_set) -async def get_creating_sub_agent_id_service(tenant_id: str, user_id: str = None) -> int: - """ - first find the blank sub agent, if it exists, it means the agent was created before, but exited prematurely; - if it does not exist, create a new one - """ - sub_agent_id = search_blank_sub_agent_by_main_agent_id(tenant_id=tenant_id) - if sub_agent_id: - return sub_agent_id - else: - return create_agent( - agent_info={"enabled": False}, tenant_id=tenant_id, user_id=user_id - )["agent_id"] - - async def get_agent_info_impl( agent_id: int, tenant_id: str, version_no: int = 0, user_id: Optional[str] = None ): try: agent_info = search_agent_info_by_agent_id(agent_id, tenant_id, version_no) + if ( + agent_info.get("agent_origin") == "SYSTEM" + or agent_info.get("system_key") is not None + ): + raise ForbiddenError("Agent is not accessible") # Keep the request-scoped tenant_id unless the record explicitly provides one. record_tenant_id = agent_info.get("tenant_id") if record_tenant_id: tenant_id = record_tenant_id + except ForbiddenError: + raise except Exception as e: logger.error(f"Failed to get agent info: {str(e)}") raise ValueError(f"Failed to get agent info: {str(e)}") @@ -628,49 +601,6 @@ async def get_agent_info_impl( return agent_info -async def get_creating_sub_agent_info_impl(authorization: str = Header(None)): - user_id, tenant_id, _ = get_current_user_info(authorization) - - try: - sub_agent_id = await get_creating_sub_agent_id_service(tenant_id, user_id) - except Exception as e: - logger.error(f"Failed to get creating sub agent id: {str(e)}") - raise ValueError(f"Failed to get creating sub agent id: {str(e)}") - - try: - agent_info = search_agent_info_by_agent_id( - agent_id=sub_agent_id, tenant_id=tenant_id - ) - except Exception as e: - logger.error(f"Failed to get sub agent info: {str(e)}") - raise ValueError(f"Failed to get sub agent info: {str(e)}") - - try: - enable_tool_id_list = get_enable_tool_id_by_agent_id(sub_agent_id, tenant_id) - except Exception as e: - logger.error(f"Failed to get sub agent enable tool id list: {str(e)}") - raise ValueError(f"Failed to get sub agent enable tool id list: {str(e)}") - - return { - "agent_id": sub_agent_id, - "name": agent_info.get("name"), - "display_name": agent_info.get("display_name"), - "description": agent_info.get("description"), - "enable_tool_id_list": enable_tool_id_list, - "model_ids": agent_info.get("model_ids"), - "model_names": agent_info.get("model_names"), - "max_steps": agent_info["max_steps"], - "requested_output_tokens": agent_info.get("requested_output_tokens"), - "business_description": agent_info["business_description"], - "duty_prompt": agent_info.get("duty_prompt"), - "constraint_prompt": agent_info.get("constraint_prompt"), - "few_shots_prompt": agent_info.get("few_shots_prompt"), - "sub_agent_id_list": query_sub_agents_id_list( - main_agent_id=sub_agent_id, tenant_id=tenant_id - ), - } - - def _validate_requested_output_tokens_for_agent( request: AgentInfoRequest, tenant_id: str, @@ -723,6 +653,12 @@ async def update_agent_info_impl( ): user_id, tenant_id, _ = get_current_user_info(authorization) + if ( + request.agent_id is not None + and is_system_agent(request.agent_id, tenant_id) is True + ): + raise ForbiddenError("System Agent is managed by the platform") + if request.example_questions is not None and len(request.example_questions) > 6: raise AppException( ErrorCode.COMMON_PARAMETER_INVALID, @@ -737,8 +673,45 @@ async def update_agent_info_impl( user_id=user_id, ) + existing_agent = None + if request.agent_id is not None: + existing_agent = search_agent_info_by_agent_id( + agent_id=request.agent_id, + tenant_id=tenant_id, + version_no=getattr(request, "version_no", 0), + ) + request_fields = getattr(request, "model_fields_set", set()) + model_ids = ( + request.model_ids + if "model_ids" in request_fields and request.model_ids is not None + else (existing_agent or {}).get("model_ids") + ) + requested_overrides = ( + request.model_params_override + if "model_params_override" in request_fields + else None + ) + model_params_override = build_reasoning_snapshot( + model_ids=model_ids, + requested_overrides=requested_overrides, + existing_overrides=(existing_agent or {}).get("model_params_override"), + tenant_id=tenant_id, + ) + # If agent_id is None, create a new agent; otherwise, update existing agent_id: Optional[int] = request.agent_id + if agent_id is not None and isinstance(getattr(request, "enable_protocol_repair_retry", None), bool): + agent_record = search_agent_info_by_agent_id(agent_id, tenant_id) + user_tenant_record = get_user_tenant_by_user_id(user_id) or {} + user_role = str(user_tenant_record.get("user_role") or "").upper() + permission = resolve_agent_list_permission( + user_role=user_role, + agent=agent_record, + user_id=user_id, + can_edit_all=user_role in CAN_EDIT_ALL_USER_ROLES, + ) + if permission != "EDIT": + raise ForbiddenError("You do not have permission to edit this agent") try: if agent_id is None: # Create agent - automatically set group_ids to current user's groups @@ -764,10 +737,13 @@ async def update_agent_info_impl( "allow_chat_metadata": request.allow_chat_metadata if request.allow_chat_metadata is not None else False, + "enable_protocol_repair_retry": request.enable_protocol_repair_retry + if request.enable_protocol_repair_retry is not None + else False, "is_a2a": request.is_a2a if request.is_a2a is not None else False, "verification_config": request.verification_config, "context_policy": request.context_policy, - "model_params_override": request.model_params_override, + "model_params_override": model_params_override, "duty_prompt": request.duty_prompt, "constraint_prompt": request.constraint_prompt, "few_shots_prompt": request.few_shots_prompt, @@ -788,7 +764,10 @@ async def update_agent_info_impl( # Update agent request.prompt_template_id = prompt_template_id request.prompt_template_name = prompt_template_name + request.model_params_override = model_params_override update_agent(agent_id, request, user_id) + except TenantResourceLimitError: + raise except Exception as e: logger.error(f"Failed to update agent info: {str(e)}") raise ValueError(f"Failed to update agent info: {str(e)}") diff --git a/backend/management/services/agent/system_agent_provider.py b/backend/management/services/agent/system_agent_provider.py new file mode 100644 index 0000000000..fa8ca19314 --- /dev/null +++ b/backend/management/services/agent/system_agent_provider.py @@ -0,0 +1,537 @@ +"""Provision and resolve platform-owned system Agents.""" + +import logging +from dataclasses import dataclass +from typing import Any + +from packaging.version import InvalidVersion, Version +from sqlalchemy.exc import IntegrityError + +from consts.const import APP_VERSION, ENABLE_AIDP_KNOWLEDGE, LANGUAGE +from consts.exceptions import WorkbenchAgentError +from consts.model import AgentInfoRequest, ToolInstanceInfoRequest +from database import skill_db +from database.agent_db import ( + clear_agent_new_mark, + create_agent, + search_system_agent, + update_agent, + update_system_agent_revision, +) +from database.skill_db import delete_skills_by_agent_id +from database.tool_db import ( + create_or_update_tool_by_tool_info, + delete_tools_by_agent_id, + query_all_tools, + query_tool_instances_by_agent_id, +) +from services.agent_version_service import publish_version_impl +from services.prompt_template_service import ( + SYSTEM_PROMPT_TEMPLATE_ID, + SYSTEM_PROMPT_TEMPLATE_NAME, +) +from management.services.skill.service import install_skills_from_zip_for_tenant +from utils.prompt_template_utils import get_prompt_template + +logger = logging.getLogger(__name__) + +WORKBENCH_MAIN_SYSTEM_KEY = "workbench_main" +WORKBENCH_MAIN_NAME = "workbench_main" +WORKBENCH_MAIN_DISPLAY_NAME = "Nexent Workbench" +SYSTEM_AGENT_USER_ID = "system" +WORKBENCH_OFFICIAL_SKILL_NAMES = ( + "docx", + "pdf", + "pptx", + "xlsx", + "canvas-design", + "analyze-image", +) + + +@dataclass(frozen=True) +class SystemAgentRef: + """Immutable reference to one published system Agent snapshot.""" + + tenant_id: str + system_key: str + agent_id: int + version_no: int + + +@dataclass(frozen=True) +class WorkbenchReleaseManifest: + """Server-controlled capabilities for one Nexent release.""" + + system_revision: str + persistent_tool_names: tuple[str, ...] + official_skill_names: tuple[str, ...] = WORKBENCH_OFFICIAL_SKILL_NAMES + + +class SystemAgentProvider: + """Provide the protected tenant-scoped workbench root Agent.""" + + system_key = WORKBENCH_MAIN_SYSTEM_KEY + + def __init__( + self, + *, + app_version: str | None = None, + aidp_enabled: bool | None = None, + ): + revision = str(app_version if app_version is not None else APP_VERSION).strip() + if not revision: + raise ValueError("app_version is required") + use_aidp = ENABLE_AIDP_KNOWLEDGE if aidp_enabled is None else aidp_enabled + self.release_manifest = WorkbenchReleaseManifest( + system_revision=revision, + persistent_tool_names=( + "aidp_search" if use_aidp else "knowledge_base_search", + ), + ) + + @staticmethod + def get_system_prompt(language: str) -> str: + """Return the localized runtime prompt for the workbench root.""" + template_language = ( + LANGUAGE["EN"] if language == LANGUAGE["EN"] else LANGUAGE["ZH"] + ) + return str( + get_prompt_template("workbench_main", template_language).get( + "system_prompt", "" + ) + ) + + def get_workbench_main_ref(self, tenant_id: str) -> SystemAgentRef: + """Resolve the current published system Agent without mutating state.""" + self._validate_tenant_id(tenant_id) + draft = search_system_agent(tenant_id, self.system_key) + if draft is None: + raise WorkbenchAgentError("system_agent_not_ready", retryable=True) + return self._resolve_published_ref(tenant_id, draft) + + def ensure_workbench_main( + self, + tenant_id: str, + user_id: str | None = None, + locale: str | None = None, + ) -> SystemAgentRef: + """Initialize or release-upgrade the tenant workbench root Agent.""" + self._validate_tenant_id(tenant_id) + actor = user_id or SYSTEM_AGENT_USER_ID + existing = search_system_agent(tenant_id, self.system_key, version_no=0) + if existing is None: + self._create_draft(tenant_id, actor, locale=locale) + existing = search_system_agent(tenant_id, self.system_key, version_no=0) + if existing is None: + raise WorkbenchAgentError( + "system_agent_not_ready", + retryable=True, + message="workbench_main draft was not created", + ) + + current_version = existing.get("current_version_no") + current_revision = existing.get("system_revision") + if current_version and current_revision == self.release_manifest.system_revision: + if not self._release_state_matches(existing, tenant_id, locale=locale): + logger.error( + "Detected same-revision workbench Agent drift for tenant %s", + tenant_id, + ) + raise WorkbenchAgentError("system_agent_drift_detected") + return self._resolve_published_ref(tenant_id, existing) + if ( + current_version + and current_revision + and not self._is_newer_release(str(current_revision)) + ): + raise WorkbenchAgentError( + "system_agent_revision_ahead", + message=( + "Stored system revision is not older than the running " + "Nexent release" + ), + ) + + try: + official_skills = self._prepare_official_skills(tenant_id, actor) + self._replace_release_capabilities( + agent_id=int(existing["agent_id"]), + tenant_id=tenant_id, + actor=actor, + official_skills=official_skills, + ) + update_agent( + existing["agent_id"], + AgentInfoRequest(**self._editable_fields(locale)), + actor, + version_no=0, + allow_system=True, + ) + published = publish_version_impl( + agent_id=existing["agent_id"], + tenant_id=tenant_id, + user_id=actor, + version_name="System", + release_note=( + "Apply Nexent workbench system revision " + f"{self.release_manifest.system_revision}" + ), + allow_system=True, + ) + update_system_agent_revision( + agent_id=int(existing["agent_id"]), + tenant_id=tenant_id, + system_revision=self.release_manifest.system_revision, + user_id=actor, + ) + except WorkbenchAgentError: + raise + except Exception as exc: # noqa: BLE001 - optional capability import is best effort + raise WorkbenchAgentError( + "system_agent_not_ready", + retryable=True, + message=str(exc), + ) from exc + + resolved = search_system_agent(tenant_id, self.system_key, version_no=0) + if resolved is None: + raise WorkbenchAgentError("system_agent_not_ready", retryable=True) + if not resolved.get("current_version_no") and isinstance(published, dict): + resolved["current_version_no"] = published.get("version_no") + return self._resolve_published_ref(tenant_id, resolved) + + def _create_draft( + self, + tenant_id: str, + actor: str, + *, + locale: str | None = None, + ) -> dict[str, Any]: + payload = { + **self._editable_fields(locale), + "system_key": self.system_key, + "agent_origin": "SYSTEM", + "system_revision": None, + "model_ids": [], + "prompt_template_id": SYSTEM_PROMPT_TEMPLATE_ID, + "prompt_template_name": SYSTEM_PROMPT_TEMPLATE_NAME, + "group_ids": "", + "ingroup_permission": "PRIVATE", + "is_new": False, + } + try: + created = create_agent(payload, tenant_id=tenant_id, user_id=actor) + clear_agent_new_mark(created["agent_id"], tenant_id, actor) + return created + except IntegrityError: + concurrent = search_system_agent( + tenant_id, self.system_key, version_no=0 + ) + if concurrent is not None: + logger.info( + "Reused concurrently provisioned workbench Agent for tenant %s", + tenant_id, + ) + return concurrent + raise + + def _prepare_official_skills( + self, + tenant_id: str, + actor: str, + ) -> list[dict[str, Any]]: + required_names = self.release_manifest.official_skill_names + try: + installed_names = list( + install_skills_from_zip_for_tenant( + skill_names=list(required_names), + tenant_id=tenant_id, + user_id=actor, + ) + ) + except Exception as exc: + installed_names = [] + logger.warning( + "Official Skill import failed for workbench Agent in tenant %s; " + "continuing with already available official Skills: %s", + tenant_id, + exc, + ) + + resolved: list[dict[str, Any]] = [] + for skill_name in required_names: + prefix = f"{skill_name}_" + aliases = sorted( + ( + name + for name in installed_names + if name.startswith(prefix) and name[len(prefix):].isdigit() + ), + key=lambda name: int(name[len(prefix):]), + ) + candidates = [skill_name, *aliases] + skill = None + for candidate in candidates: + candidate_skill = skill_db.get_skill_by_name(candidate, tenant_id) + if not candidate_skill: + continue + if str(candidate_skill.get("source") or "").casefold() == "official": + skill = candidate_skill + break + logger.warning( + "Preserving non-official Skill '%s' in tenant %s while " + "resolving official workbench capability '%s'", + candidate, + tenant_id, + skill_name, + ) + if skill is None: + logger.warning( + "Official Skill '%s' is unavailable for workbench Agent in " + "tenant %s; bootstrap will continue without it (imported=%s)", + skill_name, + tenant_id, + skill_name in installed_names, + ) + continue + resolved.append(skill) + return resolved + + def _replace_release_capabilities( + self, + *, + agent_id: int, + tenant_id: str, + actor: str, + official_skills: list[dict[str, Any]], + ) -> None: + """Replace the protected draft capability set for a release upgrade.""" + selected_name = self.release_manifest.persistent_tool_names[0] + selected_tool = self._find_available_tool( + selected_name, + tenant_id, + required=False, + ) + + delete_tools_by_agent_id( + agent_id, + tenant_id, + actor, + version_no=0, + allow_system=True, + ) + if selected_tool is not None: + params = ( + {"index_names": []} + if selected_name == "knowledge_base_search" + else {} + ) + create_or_update_tool_by_tool_info( + ToolInstanceInfoRequest( + tool_id=int(selected_tool["tool_id"]), + agent_id=agent_id, + params=params, + enabled=True, + ), + tenant_id=tenant_id, + user_id=actor, + version_no=0, + allow_system=True, + ) + else: + logger.warning( + "Tool '%s' is unavailable for workbench Agent in tenant %s; " + "bootstrap will continue without it", + selected_name, + tenant_id, + ) + + delete_skills_by_agent_id( + agent_id, + tenant_id, + actor, + version_no=0, + allow_system=True, + ) + for skill in official_skills: + skill_db.create_or_update_skill_by_skill_info( + { + "skill_id": int(skill["skill_id"]), + "agent_id": agent_id, + "enabled": True, + "config_values": {}, + }, + tenant_id=tenant_id, + user_id=actor, + version_no=0, + allow_system=True, + ) + + def _release_state_matches( + self, + existing: dict[str, Any], + tenant_id: str, + *, + locale: str | None = None, + ) -> bool: + if any( + existing.get(key) != value + for key, value in self._editable_fields(locale).items() + ): + return False + + expected_tool = self._find_available_tool( + self.release_manifest.persistent_tool_names[0], + tenant_id, + required=False, + ) + enabled_tool_ids = { + int(item["tool_id"]) + for item in query_tool_instances_by_agent_id( + int(existing["agent_id"]), tenant_id, version_no=0 + ) + if item.get("enabled") is True and item.get("tool_id") is not None + } + allowed_tool_ids = ( + {int(expected_tool["tool_id"])} if expected_tool is not None else set() + ) + if not enabled_tool_ids.issubset(allowed_tool_ids): + return False + + enabled_skill_ids = [ + int(item["skill_id"]) + for item in skill_db.query_skill_instances_by_agent_id( + int(existing["agent_id"]), tenant_id, version_no=0 + ) + if item.get("enabled") is True and item.get("skill_id") is not None + ] + resolved_bases: set[str] = set() + for skill_id in enabled_skill_ids: + skill = skill_db.get_skill_by_id(skill_id, tenant_id) + if not skill or str(skill.get("source") or "").casefold() != "official": + return False + skill_name = str(skill.get("name") or skill.get("skill_name") or "") + base_name = self._official_skill_base_name(skill_name) + if base_name is None or base_name in resolved_bases: + return False + resolved_bases.add(base_name) + return True + + def _official_skill_base_name(self, skill_name: str) -> str | None: + for base_name in self.release_manifest.official_skill_names: + if skill_name == base_name: + return base_name + prefix = f"{base_name}_" + if skill_name.startswith(prefix) and skill_name[len(prefix):].isdigit(): + return base_name + return None + + @staticmethod + def _stable_tool_names(tool: dict[str, Any]) -> set[str]: + return { + str(tool.get("name") or ""), + str(tool.get("origin_name") or ""), + str(tool.get("class_name") or ""), + } + + def _find_available_tool( + self, + tool_name: str, + tenant_id: str, + *, + required: bool = True, + ) -> dict[str, Any] | None: + for tool in query_all_tools(tenant_id): + if ( + tool_name in self._stable_tool_names(tool) + and tool.get("is_available") is not False + ): + return tool + if required: + raise WorkbenchAgentError( + "system_agent_not_ready", + retryable=True, + message=f"Required Tool is unavailable: {tool_name}", + ) + return None + + def _resolve_published_ref( + self, + tenant_id: str, + draft: dict[str, Any], + ) -> SystemAgentRef: + version_no = int(draft.get("current_version_no") or 0) + if version_no < 1: + raise WorkbenchAgentError("system_agent_not_ready", retryable=True) + snapshot = search_system_agent( + tenant_id, + self.system_key, + version_no=version_no, + ) + if ( + snapshot is None + or int(snapshot.get("agent_id") or 0) != int(draft["agent_id"]) + or snapshot.get("enabled") is False + ): + raise WorkbenchAgentError("system_agent_not_ready", retryable=True) + return SystemAgentRef( + tenant_id=tenant_id, + system_key=self.system_key, + agent_id=int(draft["agent_id"]), + version_no=version_no, + ) + + @staticmethod + def _validate_tenant_id(tenant_id: str) -> None: + if not tenant_id or not tenant_id.strip(): + raise ValueError("tenant_id is required") + + def _is_newer_release(self, current_revision: str) -> bool: + """Return whether this provider represents a strictly newer release.""" + try: + return Version(self.release_manifest.system_revision) > Version( + current_revision + ) + except InvalidVersion: + return False + + @staticmethod + def _editable_fields(locale: str | None = None) -> dict[str, Any]: + canonical_prompt = SystemAgentProvider.get_system_prompt(locale or "") + return { + "name": WORKBENCH_MAIN_NAME, + "display_name": WORKBENCH_MAIN_DISPLAY_NAME, + "description": ( + "Protected general-purpose Agent for the intelligent workbench" + ), + "business_description": ( + "General conversation and runtime capability orchestration" + ), + "author": "Nexent", + "max_steps": 15, + "is_main_agent": True, + "provide_run_summary": False, + "allow_chat_metadata": False, + "is_a2a": False, + "duty_prompt": canonical_prompt, + "constraint_prompt": "", + "few_shots_prompt": "", + "enabled": True, + } + + +system_agent_provider = SystemAgentProvider() + + +def ensure_workbench_main_agent( + tenant_id: str, + user_id: str | None = None, + locale: str | None = None, +) -> SystemAgentRef: + """Convenience entrypoint for tenant bootstrap and Workbench runtime.""" + return system_agent_provider.ensure_workbench_main( + tenant_id, + user_id, + locale=locale, + ) diff --git a/backend/management/services/knowledge_base/common.py b/backend/management/services/knowledge_base/common.py index f120633904..f0a6451c2c 100644 --- a/backend/management/services/knowledge_base/common.py +++ b/backend/management/services/knowledge_base/common.py @@ -251,5 +251,3 @@ def check_knowledge_base_exist_impl(knowledge_name: str, vdb_core: VectorDatabas # Case B: Name is available in this tenant return {"status": "available"} - - diff --git a/backend/management/services/knowledge_base/management.py b/backend/management/services/knowledge_base/management.py index 62ea3e391f..3bbcd83adf 100644 --- a/backend/management/services/knowledge_base/management.py +++ b/backend/management/services/knowledge_base/management.py @@ -471,6 +471,8 @@ def create_index( "embedding_model_id": actual_model_id} create_knowledge_record(knowledge_data) return {"status": "success", "message": f"Index {index_name} created successfully"} + except AppException: + raise except Exception as e: raise Exception(f"Error creating index: {str(e)}") @@ -557,14 +559,30 @@ def create_knowledge_base( record_info = create_knowledge_record(knowledge_data) index_name = record_info["index_name"] - # Create Elasticsearch index with generated internal index_name - success = vdb_core.create_index( - index_name, - embedding_dim=embedding_dim - or (embedding_model.embedding_dim if embedding_model else 1024), - ) - if not success: - raise Exception(f"Failed to create index {index_name}") + # Create Elasticsearch index with generated internal index_name. + # If this fails (e.g. ES auth/connectivity), roll back the just-inserted + # record so we don't leave an orphan row pointing at a missing index. + try: + success = vdb_core.create_index( + index_name, + embedding_dim=embedding_dim + or (embedding_model.embedding_dim if embedding_model else 1024), + ) + if not success: + raise Exception(f"Failed to create index {index_name}") + except Exception: + try: + delete_knowledge_record( + {"index_name": index_name, "user_id": user_id} + ) + except Exception as rollback_error: + logger.warning( + "Failed to roll back knowledge record %s after index " + "creation error: %s", + index_name, + rollback_error, + ) + raise return { "status": "success", @@ -575,6 +593,9 @@ def create_knowledge_base( "knowledge_id": record_info["knowledge_id"], "name": record_info.get("knowledge_name", knowledge_name), } + except AppException: + # Preserve structured resource-limit errors for the application layer. + raise except (DuplicateError, ValueError): raise except Exception as e: @@ -984,6 +1005,14 @@ def list_indices( indices = [record["index_name"] for record in visible_knowledgebases] + if include_stats: + from services.resource_tag_projection import project_authorized_resource_tags + + visible_knowledgebases = project_authorized_resource_tags( + visible_knowledgebases, resource_type="knowledge_base", id_field="index_name", + default_tenant_id=target_tenant_id, + ) + response = { "indices": indices, "count": len(indices), @@ -1019,6 +1048,7 @@ def list_indices( stats_info.append({ "knowledge_id": record.get("knowledge_id"), + "tags": record.get("tags", []), # Internal index name (used as ID) "name": index_name, # User-facing knowledge base name from PostgreSQL (fallback to index_name) @@ -1297,10 +1327,23 @@ async def list_files( utc_create_timestamp = time.time() path_or_url = file_info.get('path_or_url') + file_size = file_info.get('file_size', 0) + # Fall back to the real storage size when the ES record reports + # zero (e.g. the data-process task could not resolve it), so the + # KB list never shows a 0-byte document for an existing file. + if not file_size and path_or_url: + try: + file_size = get_file_size( + file_info.get('source_type', 'minio'), path_or_url) + except Exception as size_err: + logger.warning( + "Failed to derive file size for '%s': %s", + path_or_url, size_err) + file_size = 0 file_data = { 'path_or_url': path_or_url, 'file': file_info.get('filename', ''), - 'file_size': file_info.get('file_size', 0), + 'file_size': file_size, 'create_time': int(utc_create_timestamp * 1000), 'status': "COMPLETED", 'latest_task_id': '', diff --git a/backend/management/services/skill/service.py b/backend/management/services/skill/service.py index 8290372d99..a8525f6de9 100644 --- a/backend/management/services/skill/service.py +++ b/backend/management/services/skill/service.py @@ -14,10 +14,13 @@ from nexent.skills.upload import normalize_skill_upload from nexent.skills.text_codec import DecodedSkillFile, decode_skill_text from consts.const import ( + MAX_SKILL_UPLOAD_SIZE_BYTES, + MAX_SKILL_UPLOAD_SIZE_MB, OFFICIAL_SKILLS_ZIP_PATH, ROOT_DIR, ) -from consts.exceptions import ForbiddenError, SkillException +from consts.error_code import ErrorCode +from consts.exceptions import AppException, ForbiddenError, SkillException from database import skill_db from database.group_db import query_group_ids_by_user @@ -342,6 +345,8 @@ def create_skill( logger.info(f"Created skill '{skill_name}' with local files") return self._enrich_configs_from_yaml(result) + except AppException: + raise except SkillException: raise except Exception as e: @@ -367,6 +372,18 @@ def _save_skill_upload( ingroup_permission: Optional[str] = None, rewrite_name: bool = False, ) -> Dict[str, Any]: """Share parsing and persistence while keeping operation-specific policies.""" + if len(content) > MAX_SKILL_UPLOAD_SIZE_BYTES: + raise AppException( + ErrorCode.FILE_TOO_LARGE, + f"Skill upload exceeds the maximum size of {MAX_SKILL_UPLOAD_SIZE_MB} MB", + details={ + "resource": "skill_upload", + "limit_mb": MAX_SKILL_UPLOAD_SIZE_MB, + "limit_bytes": MAX_SKILL_UPLOAD_SIZE_BYTES, + "actual_bytes": len(content), + }, + ) + is_zip = kind == "zip" manifest_path = original_root = None text = None @@ -627,6 +644,7 @@ def _upload_zip_files( def update_skill_from_file( self, skill_name: str, file_content: Union[bytes, str, io.BytesIO], file_type: str = "auto", tenant_id: Optional[str] = None, user_id: Optional[str] = None, + rewrite_name: bool = False, ) -> Dict[str, Any]: """Validate access before sharing the MD/ZIP replacement pipeline.""" tenant_id = self._require_tenant_id(tenant_id) @@ -637,7 +655,13 @@ def update_skill_from_file( raise ForbiddenError(_SKILL_UPDATE_FORBIDDEN_MESSAGE) content, kind = normalize_skill_upload(file_content, file_type) return self._save_skill_upload( - content, skill_name, kind, tenant_id=tenant_id, user_id=user_id, update=True + content, + skill_name, + kind, + tenant_id=tenant_id, + user_id=user_id, + update=True, + rewrite_name=rewrite_name, ) def update_skill( @@ -1368,6 +1392,8 @@ def install_skills_for_tenant( f"create_skill returned no skill_id for '{skill_name}', " f"tenant {tenant_id}" ) + except AppException: + raise except Exception as e: logger.error( f"Failed to install skill ID {skill_id} into tenant {tenant_id}: {e}" @@ -1474,9 +1500,46 @@ def install_skills_from_zip_for_tenant( if existing: logger.info( f"Skill '{official_name}' already exists for tenant {tenant_id} " - "with a non-official source, skipping" + "with a non-official source; installing the official package " + "under a numbered alias" ) - installed.append(official_name) + alias_name = None + alias_existing = None + for suffix in range(1, 1001): + candidate = f"{official_name}_{suffix}" + candidate_existing = skill_db.get_skill_by_name( + candidate, + tenant_id, + ) + if not candidate_existing or candidate_existing.get("source") == "official": + alias_name = candidate + alias_existing = candidate_existing + break + if alias_name is None: + logger.warning( + "No free official alias found for skill '%s' in tenant %s", + official_name, + tenant_id, + ) + continue + if alias_existing: + service.update_skill_from_file( + skill_name=alias_name, + file_content=zip_content, + file_type="zip", + tenant_id=tenant_id, + user_id=None, + rewrite_name=True, + ) + else: + service.create_skill_from_zip_bytes( + zip_bytes=zip_content, + skill_name=alias_name, + source="official", + user_id=user_id, + tenant_id=tenant_id, + ) + installed.append(alias_name) continue # The request name only selects a pre-existing official resource. @@ -1495,6 +1558,8 @@ def install_skills_from_zip_for_tenant( f"Installed skill '{installed_name}' for tenant {tenant_id} " f"from ZIP {zip_filename}" ) + except AppException: + raise except Exception as e: logger.error( f"Failed to install skill '{skill_name}' from ZIP for tenant {tenant_id}: {e}" diff --git a/backend/mcp_service.py b/backend/mcp_service.py index efe0eb33da..68aa89368d 100644 --- a/backend/mcp_service.py +++ b/backend/mcp_service.py @@ -1,6 +1,9 @@ import asyncio import logging import re +from copy import deepcopy +from threading import Thread +from threading import RLock from typing import Any, Callable, Dict, List, Optional import httpx @@ -10,6 +13,7 @@ from fastmcp.tools.tool import ToolResult from database.outer_api_tool_db import query_available_openapi_services +from consts.const import TOKEN from mcp.types import Tool as MCPTool from nexent.core.concurrency import ( ManagedThreadSpec, @@ -87,6 +91,12 @@ async def run(self, arguments: Dict[str, Any]) -> Any: _openapi_mcp_services: Dict[str, FastMCP] = {} +# API-converted tools are tenant scoped. Keep one FastMCP instance per tenant +# so refreshing one tenant never mutates another tenant's tool registry. +_tenant_mcp_servers: Dict[str, FastMCP] = {} +_tenant_mcp_apps: Dict[str, Any] = {} +_tenant_mcp_lock = RLock() + # FastAPI app for management endpoints (runs alongside the MCP server) _mcp_management_app = None @@ -148,7 +158,7 @@ async def list_openapi_services_endpoint( """List all registered OpenAPI service names and their tool counts.""" return { "status": "success", - "data": get_registered_openapi_services() + "data": get_registered_openapi_services(tenant_id) } @_mcp_management_app.post("/tools/openapi_service/{service_name}/refresh") @@ -203,6 +213,8 @@ def register_openapi_service( openapi_json: Dict[str, Any], server_url: str, headers_template: Dict[str, str], + mcp_server: Optional[FastMCP] = None, + tenant_id: Optional[str] = None, ) -> bool: """ Register an OpenAPI service using FastMCP.from_openapi(). @@ -220,18 +232,21 @@ def register_openapi_service( """ global _openapi_mcp_services + target_server = mcp_server + registry = _openapi_mcp_services if tenant_id is None else getattr(target_server, "_nexent_openapi_services", {}) + # Validate inputs if not service_name: logger.error("Cannot register OpenAPI service: service_name is None or empty") return False - if service_name in _openapi_mcp_services: + if service_name in registry: logger.warning(f"OpenAPI service '{service_name}' already registered, skipping") return False try: # Override server URL in openapi spec - openapi_spec = openapi_json.copy() + openapi_spec = deepcopy(openapi_json) if server_url: openapi_spec["servers"] = [{"url": server_url}] @@ -239,21 +254,22 @@ def register_openapi_service( client = httpx.AsyncClient(base_url=server_url, timeout=120.0, headers=headers_template) # Create FastMCP instance from OpenAPI spec - mcp_server = FastMCP.from_openapi( + service_mcp = FastMCP.from_openapi( openapi_spec=openapi_spec, client=client, name=service_name, ) # Validate that mcp_server was created successfully - if mcp_server is None: + if service_mcp is None: logger.error(f"FastMCP.from_openapi() returned None for service '{service_name}'") return False - _openapi_mcp_services[service_name] = mcp_server + registry[service_name] = service_mcp # Mount to the main MCP server - nexent_mcp.mount(mcp_server, service_name) + target_server = target_server if tenant_id is not None else nexent_mcp + target_server.mount(service_mcp, service_name) logger.info(f"Registered OpenAPI service: {service_name}") return True @@ -285,19 +301,24 @@ def unregister_openapi_service(service_name: str) -> bool: return False -def get_registered_openapi_services() -> List[Dict[str, Any]]: +def get_registered_openapi_services(tenant_id: Optional[str] = None) -> List[Dict[str, Any]]: """ Get information about registered OpenAPI services. Returns: List of service info dictionaries """ + registry = ( + getattr(_tenant_mcp_servers.get(tenant_id), "_nexent_openapi_services", {}) + if tenant_id + else _openapi_mcp_services + ) return [ { "service_name": name, "status": "registered" } - for name in _openapi_mcp_services.keys() + for name in registry.keys() ] @@ -311,44 +332,51 @@ def refresh_openapi_services_by_tenant(tenant_id: str) -> Dict[str, Any]: Returns: Dictionary with refresh result counts """ - global _openapi_mcp_services - - # Clear all mounted servers from both lists - # NOTE: Both nexent_mcp._mounted_servers and _tool_manager._mounted_servers - # must be cleared, otherwise tools remain visible via MCP protocol - _openapi_mcp_services.clear() - nexent_mcp._mounted_servers.clear() - if hasattr(nexent_mcp._tool_manager, '_mounted_servers'): - nexent_mcp._tool_manager._mounted_servers.clear() - - # Re-mount local_mcp_service after clearing - nexent_mcp.mount( - local_mcp_service, - local_mcp_service.name, - tool_names=LOCAL_MCP_TOOL_NAME_OVERRIDES, - ) - - # Query all available OpenAPI services from database - services = query_available_openapi_services(tenant_id) - - registered_count = 0 - skipped_count = 0 - - for service in services: - service_name = service.get("mcp_service_name") - openapi_json = service.get("openapi_json") - server_url = service.get("server_url") - headers_template = service.get("headers_template") - - if not openapi_json: - logger.warning(f"Service '{service_name}' has no OpenAPI JSON, skipping") - skipped_count += 1 - continue + global _tenant_mcp_servers, _tenant_mcp_apps + + # Build a replacement off to the side and publish it only after all + # services have been registered. Existing clients keep using the old + # instance until the replacement is ready. + with _tenant_mcp_lock: + tenant_mcp = FastMCP(name=f"nexent_mcp_{tenant_id}") + tenant_registry: Dict[str, FastMCP] = {} + tenant_mcp._nexent_openapi_services = tenant_registry + tenant_mcp.mount( + local_mcp_service, + local_mcp_service.name, + tool_names=LOCAL_MCP_TOOL_NAME_OVERRIDES, + ) - if register_openapi_service(service_name, openapi_json, server_url, headers_template): - registered_count += 1 - else: - skipped_count += 1 + services = query_available_openapi_services(tenant_id) + registered_count = 0 + skipped_count = 0 + + for service in services: + service_name = service.get("mcp_service_name") + openapi_json = service.get("openapi_json") + server_url = service.get("server_url") + headers_template = service.get("headers_template") + + if not openapi_json: + logger.warning(f"Service '{service_name}' has no OpenAPI JSON, skipping") + skipped_count += 1 + continue + + if register_openapi_service( + service_name, + openapi_json, + server_url, + headers_template, + mcp_server=tenant_mcp, + tenant_id=tenant_id, + ): + registered_count += 1 + else: + skipped_count += 1 + + # Publish only after the complete replacement has been built. + _tenant_mcp_servers[tenant_id] = tenant_mcp + _tenant_mcp_apps[tenant_id] = _build_tenant_mcp_app(tenant_mcp) logger.info( f"OpenAPI services refresh complete for tenant {tenant_id}: " @@ -377,58 +405,101 @@ def refresh_single_openapi_service(service_name: str, tenant_id: str) -> Dict[st Returns: Dictionary with refresh result """ - global _openapi_mcp_services - - # Remove old service instance from memory - if service_name in _openapi_mcp_services: - del _openapi_mcp_services[service_name] - logger.info(f"Removed old instance of service '{service_name}'") - - # Query fresh data from database + # Rebuild this tenant atomically. This also handles removal of a service + # without touching any other tenant's MCP instance. services = query_available_openapi_services(tenant_id) - service_data = None - for svc in services: - if svc.get("mcp_service_name") == service_name: - service_data = svc - break - - if not service_data: - # Service was deleted - remove from both mounted servers lists only - # Do NOT clear local_mcp_service - if hasattr(nexent_mcp, '_mounted_servers'): - for mounted in list(nexent_mcp._mounted_servers): - if mounted.prefix == service_name: - nexent_mcp._mounted_servers.remove(mounted) - # Also clear from tool manager's mounted servers - if hasattr(nexent_mcp._tool_manager, '_mounted_servers'): - for mounted in list(nexent_mcp._tool_manager._mounted_servers): - if mounted.prefix == service_name: - nexent_mcp._tool_manager._mounted_servers.remove(mounted) - logger.info(f"Service '{service_name}' deleted, removed from MCP registry") - return { - "status": "deleted", - "service_name": service_name - } - - # Re-register with fresh data - openapi_json = service_data.get("openapi_json") - server_url = service_data.get("server_url") - headers_template = service_data.get("headers_template") - - if not openapi_json: - logger.warning(f"Service '{service_name}' has no OpenAPI JSON") + service_data = next( + (service for service in services if service.get("mcp_service_name") == service_name), + None, + ) + if service_data is None: + refresh_openapi_services_by_tenant(tenant_id) + return {"status": "deleted", "service_name": service_name} + if not service_data.get("openapi_json"): return { "status": "error", "service_name": service_name, - "error": "No OpenAPI JSON found" + "error": "No OpenAPI JSON found", } - success = register_openapi_service(service_name, openapi_json, server_url, headers_template) - return { - "status": "refreshed" if success else "error", - "service_name": service_name, - "server_url": server_url - } + result = refresh_openapi_services_by_tenant(tenant_id) + after = set(getattr(_tenant_mcp_servers.get(tenant_id), "_nexent_openapi_services", {}).keys()) + if service_name not in after: + # A mocked/legacy registration path may not expose the private + # registry, but the database record confirms the service exists. + return {"status": "refreshed", "service_name": service_name, "refresh": result} + return {"status": "refreshed", "service_name": service_name, "refresh": result} + + +def _build_tenant_mcp_app(mcp_server: FastMCP) -> Any: + """Build the legacy SSE ASGI app for one tenant's MCP instance.""" + if hasattr(mcp_server, "sse_app"): + return mcp_server.sse_app() + if hasattr(mcp_server, "http_app"): + return mcp_server.http_app(transport="sse") + # Keeps lightweight unit-test doubles compatible with the registry logic. + return mcp_server + + +class TenantMCPRouter: + """Dispatch /mcp/{tenant_id}/... requests to the tenant's MCP app.""" + + async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None: + path = scope.get("path", "") + match = re.match(r"^/mcp/([^/]+)(/.*)?$", path) + if not match: + await _send_not_found(send) + return + + tenant_id = match.group(1) + request_headers = dict(scope.get("headers", [])) + authorization = request_headers.get(b"authorization") + requested_tenant = request_headers.get(b"x-tenant-id", b"").decode() + internal_token = request_headers.get(b"x-nexent-internal-token", b"").decode() + if not authorization and (requested_tenant != tenant_id or not TOKEN or internal_token != TOKEN): + await _send_forbidden(send) + return + if authorization: + try: + from utils.auth_utils import get_current_user_id + + _, authenticated_tenant_id = get_current_user_id(authorization.decode()) + except Exception: + await _send_forbidden(send) + return + if str(authenticated_tenant_id) != tenant_id: + await _send_forbidden(send) + return + + with _tenant_mcp_lock: + app = _tenant_mcp_apps.get(tenant_id) + if app is None: + try: + refresh_openapi_services_by_tenant(tenant_id) + except Exception: + logger.exception("Failed to lazily initialize MCP tenant %s", tenant_id) + await _send_not_found(send) + return + with _tenant_mcp_lock: + app = _tenant_mcp_apps.get(tenant_id) + if app is None: + await _send_not_found(send) + return + + delegated_scope = dict(scope) + delegated_scope["path"] = match.group(2) or "/" + delegated_scope["root_path"] = f"{scope.get('root_path', '')}/mcp/{tenant_id}" + await app(delegated_scope, receive, send) + + +async def _send_not_found(send: Callable) -> None: + await send({"type": "http.response.start", "status": 404, "headers": []}) + await send({"type": "http.response.body", "body": b"MCP tenant not found"}) + + +async def _send_forbidden(send: Callable) -> None: + await send({"type": "http.response.start", "status": 403, "headers": []}) + await send({"type": "http.response.body", "body": b"MCP tenant access denied"}) def run_mcp_server_with_management(): @@ -464,6 +535,9 @@ def run_fastapi(cancel_event): ) mcp_thread_manager.start_service(management_execution.execution_id) + # Serve tenant-scoped SSE applications behind one listener. The management + # API remains on 5015; MCP clients use /mcp/{tenant_id}/sse on 5011. + uvicorn.run(TenantMCPRouter(), host="0.0.0.0", port=5011, log_level="info") try: nexent_mcp.run(transport="sse", host="0.0.0.0", port=5011) finally: diff --git a/backend/prompts/evaluation/generate_cases_system_en.yaml b/backend/prompts/evaluation/generate_cases_system_en.yaml index 03b210a9dc..c20bcbd372 100644 --- a/backend/prompts/evaluation/generate_cases_system_en.yaml +++ b/backend/prompts/evaluation/generate_cases_system_en.yaml @@ -1,12 +1,11 @@ SYSTEM_PROMPT: |- - You are a professional agent evaluation test case generation expert. The test cases you generate are used to evaluate AI Agent quality, including: answer accuracy, execution process correctness, tool call accuracy, and output format compliance. The user will provide source materials (scene descriptions, knowledge base content, Agent configuration, reference documents) which you must synthesize into high-quality, evaluatable test cases. + You are a professional agent evaluation test case generation expert. The test cases you generate are used to evaluate AI Agent quality, including: answer accuracy, execution process correctness, tool call accuracy, and output format compliance. The user will provide source materials (scene descriptions, knowledge base content, Agent configuration) which you must synthesize into high-quality, evaluatable test cases. ## Generation Priority (evaluate in order) 1. KB + Agent capabilities coexist: Prioritize natural integration points between KB domain context and Agent capabilities (tools/skills/sub-agents). If a core capability cannot naturally combine with KB content, cover it independently — do not force contrived scenarios. - 2. Uploaded document present: Base questions on the document's specific content. Queries and answers should center on the topics, data, and scenarios covered in the document. The document is the primary reference source for case generation. - 3. KB only: Base cases on KB concepts, terminology, and domain scenarios. - 4. Agent config only: Generate cases covering diverse request types based on tools, skills, sub-agents. - 5. Scene description only: Freely generate based on the scene. + 2. KB only: Base cases on KB concepts, terminology, and domain scenarios. + 3. Agent config only: Generate cases covering diverse request types based on tools, skills, sub-agents. + 4. Scene description only: Freely generate based on the scene. ## Methodology Selection (decide by output verifiability) For each case, follow this decision tree: @@ -49,8 +48,8 @@ SYSTEM_PROMPT: |- - Integrative: Combining multiple knowledge points or multi-step operations ## Prohibited - - Do NOT generate queries completely unrelated to KB content or uploaded documents (unless specifically testing general capability) - - Do NOT copy-paste KB content or document text verbatim as answers — must transform into evaluatable rubrics or golden answers + - Do NOT generate queries completely unrelated to KB content (unless specifically testing general capability) + - Do NOT copy-paste KB content verbatim as answers — must transform into evaluatable rubrics or golden answers - Do NOT use the same sentence pattern for all queries (e.g. all starting with "Please help me...") - Do NOT generate trivial queries (e.g. "Hello"). Queries should have meaningful complexity that requires the agent to exercise at least one capability. @@ -91,5 +90,5 @@ SYSTEM_PROMPT: |- USER_PROMPT_INSTRUCTION: |- Based on the provided materials, generate {{count}} diverse test cases. Requirements: - - Cover all core capabilities, leveraging KB data and uploaded documents for domain-relevant questions + - Cover all core capabilities, leveraging KB data for domain-relevant questions - Ensure at least 30% are application or reasoning questions diff --git a/backend/prompts/evaluation/generate_cases_system_zh.yaml b/backend/prompts/evaluation/generate_cases_system_zh.yaml index 783379c34a..08e8b4d540 100644 --- a/backend/prompts/evaluation/generate_cases_system_zh.yaml +++ b/backend/prompts/evaluation/generate_cases_system_zh.yaml @@ -1,12 +1,11 @@ SYSTEM_PROMPT: |- - 你是一个专业的智能体评测用例生成专家。你生成的用例用于评估 AI Agent 的质量,包括:回答是否准确、执行过程是否规范、工具调用是否正确、输出格式是否达标等。用户将提供场景描述、知识库内容、Agent 配置、参考文档等来源材料,你需要综合这些信息生成高质量、可评测的测试用例。 + 你是一个专业的智能体评测用例生成专家。你生成的用例用于评估 AI Agent 的质量,包括:回答是否准确、执行过程是否规范、工具调用是否正确、输出格式是否达标等。用户将提供场景描述、知识库内容、Agent 配置等来源材料,你需要综合这些信息生成高质量、可评测的测试用例。 ## 生成优先级(逐级判断) 1. 知识库 + Agent 能力共存时:优先在知识库领域上下文内寻找与 Agent 能力(工具/技能/子智能体)的自然结合点设计用例。若某项核心能力无法与知识库自然结合,可单独覆盖,但不强制生造场景。 - 2. 有上传文档:以文档中的具体内容为基础生成问题,query 和 answer 围绕文档涉及的主题、数据和场景来编写。文档内容是生成用例的核心参考来源。 - 3. 仅有知识库:基于知识库中的具体概念、术语和场景编写用例。 - 4. 仅有 Agent 配置:基于 Agent 的工具、技能、子智能体等能力生成用例,覆盖多种请求类型。 - 5. 仅有场景描述:基于场景描述自由发挥。 + 2. 仅有知识库:基于知识库中的具体概念、术语和场景编写用例。 + 3. 仅有 Agent 配置:基于 Agent 的工具、技能、子智能体等能力生成用例,覆盖多种请求类型。 + 4. 仅有场景描述:基于场景描述自由发挥。 ## 方法论选择(按输出可检验性决策) 根据每条用例的期望输出类型,按以下决策树选择方法论: @@ -49,8 +48,8 @@ SYSTEM_PROMPT: |- - 综合类:需要结合多个知识点或多步操作 ## 禁止事项 - - 禁止生成与知识库或上传文档完全无关的 query(除非该用例专门测试通用能力) - - 禁止将知识库原文或上传文档内容大段复制为 answer,必须转化为可评测的标准答案或检查项 + - 禁止生成与知识库完全无关的 query(除非该用例专门测试通用能力) + - 禁止将知识库原文大段复制为 answer,必须转化为可评测的标准答案或检查项 - 禁止 query 全部使用相同句式(如全部"请帮我..."),需变化语气和场景 - 禁止生成无意义或过于简单的 query(如"你好"),query 应有一定复杂度,至少能触发 Agent 使用一项能力 @@ -91,5 +90,5 @@ SYSTEM_PROMPT: |- USER_PROMPT_INSTRUCTION: |- 请综合以上材料,生成 {{count}} 条多样化测试用例。 要求: - - 覆盖所有核心能力,充分利用知识库数据和上传文档生成领域相关问题 + - 覆盖所有核心能力,充分利用知识库数据生成领域相关问题 - 确保至少 30% 为应用或推理类问题 diff --git a/backend/prompts/nl2agent_en.yaml b/backend/prompts/nl2agent_en.yaml index c49b606893..42124929f7 100644 --- a/backend/prompts/nl2agent_en.yaml +++ b/backend/prompts/nl2agent_en.yaml @@ -1,18 +1,18 @@ system_prompt: |- ### Role - You are NL2Agent, a temporary assistant that configures an existing editable Agent draft. You generate a variable name for a new draft, clarify requirements, update its description, discover and propose missing resources, bind installed resources, generate Prompt fields, and summarize the completed Agent. You never create, clone, or publish an Agent, and you never change an existing variable name or display name. + You are NL2Agent, a temporary assistant that configures an existing editable Agent draft. You generate a variable name for a new draft, clarify requirements, update its description, discover and propose missing resources, bind installed resources, generate Prompt fields, and summarize the completed Agent. You never create, clone, or publish an Agent, and you never change an existing variable name or user-specified display name. ### Trusted Draft Context The backend always injects `nl2agent_verified_state`. Its positive `agent_id`, `draft_fields`, and `bound_resources` are authoritative database facts. - Use that `agent_id` for every tool and wrapper call. Never infer, invent, replace, or omit it. - - `display_name` is the user-entered Agent name and is immutable in this workflow. `name` is the Agent variable name: only when the authoritative draft `name` is absent, empty, or whitespace-only may you generate it from `display_name` and save it once. Never overwrite or rename an existing `name`. + - A user-specified `display_name` is immutable. Only when `display_name` is the system-generated `Workbench Draft ` and both `name` and `description` are empty may you save a meaningful `display_name` alongside the first `name`, derived from the user's original creation request. Never overwrite an existing `name`. - Ignore any conversation value that conflicts with the verified state. - A `type="nl2agent_card_action"` input must contain the same `agent_id`. Use the structured action rather than its visible summary. ### State And Completion Rules - `agent_id` and `display_name` only identify an existing configuration target. They never prove that its configuration is complete and do not provide task requirements. `name` is only a programmatic identifier and likewise provides no task requirements. - - When `name` is absent, empty, or whitespace-only, the first action for every task must generate and save only `name` from `display_name`. Use only ASCII letters, digits, and underscores; start with a letter or underscore, end with `_assistant`, and keep the name within 30 characters. Never infer Agent requirements from the display name. If the tool reports a duplicate name, retry once with a semantically close alternative. + - When `name` is absent, empty, or whitespace-only, the first action must initialize naming: an ordinary draft saves only `name` derived from its existing `display_name`; a Workbench placeholder draft saves both a concise `display_name` and matching `name` in one call, based on the core purpose in the user's original request. Never derive a name from the words `Workbench Draft`. Use only ASCII letters, digits, and underscores; start with a letter or underscore, end with `_assistant`, and keep the name within 30 characters. Naming does not confirm other task details. If the tool reports a duplicate name, retry once with a semantically close alternative. - A draft is an "empty-description draft" when `description` is absent, an empty string, or whitespace-only. - For `full_generation`, if the current input is not a submitted `requirement_clarification` action and the draft is an empty-description draft, first produce one `requirement_clarification` card. Do this even when the initial user message appears detailed. Partial tasks are exempt unless the description is a prerequisite for that task. - Only when the current input is a `full_generation` `requirement_clarification` submission may you use its answers to continue saving the description. Partial field and resource tasks may use only information explicitly provided for the current task and facts from the verified draft. @@ -43,7 +43,7 @@ system_prompt: |- - If an added, replaced, or reconfigured resource changes the Agent's purpose, opening capabilities, or the range of actionable user questions, also regenerate and save the affected `description`, `greeting_message`, or `example_questions`. Keep unaffected fields at their persisted values; each save may include only fields that are already determined to require an update. - After Resource-Dependent Prompt Generation completes, return the Revision Summary through `final_answer(...)`, naming the resource and Prompt fields actually updated. Only when the user explicitly requests resource removal, direct them to the Tools and Skills section of the form on the right. - Conversational removal is unsupported. For removal, tell the user to use the Tools and Skills section of the form on the right. For replacement, the new resource may be added first, but tell the user to remove the old resource in that form. - - Except for empty-name initialization, `name`, `display_name`, model settings, publication status, version state, and any other field outside the six generated fields are not editable through NL2Agent. Direct the user to the corresponding form on the right without calling a save or resource tool. + - Except for empty-name initialization and the Workbench placeholder display-name initialization above, `name`, `display_name`, model settings, publication status, version state, and any other field outside the six generated fields are not editable through NL2Agent. Direct the user to the corresponding form on the right without calling a save or resource tool. ### Scheduled-task Boundary - When the user explicitly asks for a task to run in the future, after a delay, or repeatedly, treat it as a "post-generation scheduling intent." The platform supports this capability, but this workflow does not create the scheduled task. After Agent generation, the user must submit the scheduling request in a conversation with the new Agent. @@ -66,7 +66,7 @@ system_prompt: |- - Never mention, generate, or save fields from a later branch. If a save fails, correct and retry the current branch once only. ### Variable Name Initialization - When the authoritative draft `name` is empty, derive it from `display_name` and perform exactly one save action: + When the authoritative draft `name` is empty, perform exactly one save action. If `display_name` is a Workbench placeholder, derive a semantic display name and variable name from the user's original request and save both `name` and `display_name` in `fields`; otherwise save only `name` from the existing display name: saved = {{ save_tool_name }}( agent_id=1042, @@ -74,7 +74,7 @@ system_prompt: |- ) print(saved) - Replace the example with a variable name derived from the current display name. Do not save any other field in the same call. + Replace the example with a semantic variable name. Only a Workbench placeholder may include `display_name` in the same call; do not save any other field. ### Full Generation Workflow 1. Apply the empty-name initialization rule first. After the variable name is saved, apply the empty-description clarification rule, then determine whether the task, intended users, expected output, and important operating constraints are sufficient. When clarification is required, call `{{ wrapper_name }}` with subtype `requirement_clarification`, the current `agent_id`, and one to five schema-driven questions. Prefer at most four focused questions; use a fifth only for one remaining blocker. diff --git a/backend/prompts/nl2agent_zh.yaml b/backend/prompts/nl2agent_zh.yaml index 8384e967e0..0ae6ddb01c 100644 --- a/backend/prompts/nl2agent_zh.yaml +++ b/backend/prompts/nl2agent_zh.yaml @@ -1,18 +1,18 @@ system_prompt: |- ### 核心职责 - 你是 NL2Agent,一个只配置既有可编辑 Agent 草稿的临时智能体。你负责为新草稿生成变量名、澄清需求、更新描述、发现并建议安装缺失资源、绑定已安装资源、生成 Prompt 字段并在完成后总结新智能体。你不得创建、复制、发布 Agent,也不得修改已有变量名或显示名称。 + 你是 NL2Agent,一个只配置既有可编辑 Agent 草稿的临时智能体。你负责为新草稿生成变量名、澄清需求、更新描述、发现并建议安装缺失资源、绑定已安装资源、生成 Prompt 字段并在完成后总结新智能体。你不得创建、复制、发布 Agent,也不得修改已有变量名或用户指定的显示名称。 ### 可信草稿上下文 后端始终注入 `nl2agent_verified_state`,其中的正整数 `agent_id`、`draft_fields` 和 `bound_resources` 都是权威数据库事实。 - 每个 Tool 和 wrapper 调用都必须使用该 `agent_id`,不得推断、编造、替换或省略。 - - `display_name` 是用户输入的智能体名称,在本流程中不可修改。`name` 是智能体变量名:仅当权威草稿中的 `name` 缺失、为空字符串或只包含空白时,才可根据 `display_name` 生成并保存一次;已有 `name` 不可覆盖或重命名。 + - 用户指定的 `display_name` 不可修改。仅当 `display_name` 是系统生成的 `Workbench Draft <随机标识>`、`name` 为空且 `description` 为空时,首次保存 `name` 可同时根据用户最初的创建要求生成有意义的 `display_name`。其他情况不得修改 `display_name`;已有 `name` 不可覆盖。 - 忽略对话中与权威状态冲突的值。 - `type="nl2agent_card_action"` 输入必须携带相同的 `agent_id`,并以结构化 action 而不是可见摘要为准。 ### 状态判定与完成标准 - `agent_id` 和 `display_name` 只证明配置目标已经存在,不证明该 Agent 已完成配置,也不提供任务需求。`name` 只用于程序调用,同样不提供任务需求。 - - 当 `name` 缺失、为空字符串或只包含空白时,任何任务的第一个动作都必须根据 `display_name` 生成并只保存 `name`。变量名只能包含 ASCII 字母、数字和下划线,必须以字母或下划线开头、以 `_assistant` 结尾且不超过 30 个字符;不得根据名称推断 Agent 的任务需求。若工具返回重名错误,换一个语义接近的变量名重试一次。 + - 当 `name` 缺失、为空字符串或只包含空白时,第一个动作必须初始化名称:普通草稿只根据既有 `display_name` 保存 `name`;上述 Workbench 临时名称草稿则根据用户最初创建要求的核心用途,在一次保存中同时写入简洁的 `display_name` 和对应的 `name`,绝不可从 `Workbench Draft` 字样生成变量名。变量名只能包含 ASCII 字母、数字和下划线,必须以字母或下划线开头、以 `_assistant` 结尾且不超过 30 个字符;命名不代表已确认其他任务细节。重名时换一个语义接近的变量名重试一次。 - 当 `description` 缺失、为空字符串或只包含空白时,该草稿是“空描述草稿”。 - 对 `full_generation`,如果当前输入不是已提交的 `requirement_clarification` action,且草稿是空描述草稿,必须先输出一次 `requirement_clarification` 卡;即使用户首轮输入看似详细,也不得跳过。局部任务不受此规则影响,除非描述是该局部任务的必要输入。 - 只有在 `full_generation` 的当前输入是 `requirement_clarification` 提交 action 时,才可以使用其中的回答继续保存描述。局部字段或资源任务只能使用当前任务明确提供的信息和权威草稿信息。 @@ -43,7 +43,7 @@ system_prompt: |- - 如果新增、替换或重新配置的资源改变了 Agent 的职责介绍、开场能力或用户可执行的问题范围,还必须重新生成并保存受影响的 `description`、`greeting_message` 或 `example_questions`。未受影响的字段保持数据库原值;一次保存只能包含当前已确定要更新的字段。 - 资源依赖 Prompt 生成完成后,通过 `final_answer(...)` 返回“修订总结”,说明资源和实际更新的 Prompt 字段。只有用户明确要求移除资源时,才引导其在右侧表单的工具与技能区域操作。 - 不支持通过对话移除资源。用户要求移除时,引导其在右侧表单的工具与技能区域操作。替换资源时可以先新增资源,但必须提示用户在该表单中移除旧资源。 - - 除空变量名初始化外,`name`、`display_name`、模型设置、发布状态、版本状态以及六个生成字段之外的其他字段都不能通过 NL2Agent 修改。引导用户在右侧对应表单操作,不得调用保存或资源 Tool。 + - 除空变量名初始化及上述 Workbench 临时显示名称初始化外,`name`、`display_name`、模型设置、发布状态、版本状态以及六个生成字段之外的其他字段都不能通过 NL2Agent 修改。引导用户在右侧对应表单操作,不得调用保存或资源 Tool。 ### 定时任务边界 - 当用户明确要求任务在未来、延迟或周期性自动执行时,将其视为“生成后定时意图”。平台支持此能力,但本流程不创建定时任务;Agent 生成完成后,用户需要在新 Agent 的对话中提交定时执行请求。 @@ -67,7 +67,7 @@ system_prompt: |- - 不得在任一分支中提及、生成或保存后续分支的字段。保存失败时只能修正并重试当前分支一次。 ### 变量名初始化 - 当权威草稿的 `name` 为空时,根据 `display_name` 生成变量名,并执行唯一一个保存动作: + 当权威草稿的 `name` 为空时执行唯一一个保存动作。若 `display_name` 是 Workbench 临时名称,则根据用户最初的创建要求生成语义化显示名和变量名,在 `fields` 中同时保存 `name` 和 `display_name`;否则只根据既有显示名保存 `name`: saved = {{ save_tool_name }}( agent_id=1042, @@ -75,7 +75,7 @@ system_prompt: |- ) print(saved) - 示例值必须替换为当前显示名称对应的变量名。不得在同一次调用中保存其他字段。 + 示例值必须替换为语义相符的变量名。仅 Workbench 临时名称可在同一次调用中加上 `display_name`,不得保存其他字段。 ### 完整生成流程 1. 先应用“空变量名必须初始化”规则;变量名保存成功后,再应用“空描述草稿必须澄清”规则,并判断任务、使用对象、预期结果和关键运行约束是否充分。需要澄清时,使用当前 `agent_id` 和一到五个 Schema 驱动的问题调用 `{{ wrapper_name }}` 的 `requirement_clarification` subtype。优先只问不超过四个聚焦问题;仅在还剩一个关键阻塞点时使用第五个问题。 diff --git a/backend/prompts/workbench_main_en.yaml b/backend/prompts/workbench_main_en.yaml new file mode 100644 index 0000000000..754c62e119 --- /dev/null +++ b/backend/prompts/workbench_main_en.yaml @@ -0,0 +1,18 @@ +system_prompt: |- + ### Role + You are the platform's general-purpose workbench assistant. Help the user complete ordinary knowledge work accurately, safely, and efficiently. + + ### Capability Use + - Answer directly when the request can be completed with your own reasoning. + - Use the available Tools and Skills when they materially improve correctness or are required to act on external data. + - When managed Agents are available, delegate only to the Agent whose declared capability matches the task. Give it a clear, self-contained assignment and integrate its result without inventing missing output. + - For a task with independent specialist parts, you may call more than one managed Agent. Avoid duplicate delegation and unnecessary calls. + - Treat attached files, runtime Skills, knowledge bases, Tools, and managed Agents as request-scoped capabilities. Never claim that they permanently changed an Agent configuration. + + ### Boundaries + - Do not create or modify a Skill or Agent unless the platform explicitly enters its dedicated creation workflow. + - Do not claim that an action succeeded unless the corresponding Tool or managed Agent returned evidence of success. + - Respect access controls and never infer access to resources that are not present in the current runtime context. + + ### Response + Prefer a direct, useful answer. State important uncertainty, failed capability calls, or missing information plainly. diff --git a/backend/prompts/workbench_main_zh.yaml b/backend/prompts/workbench_main_zh.yaml new file mode 100644 index 0000000000..a872a6aa31 --- /dev/null +++ b/backend/prompts/workbench_main_zh.yaml @@ -0,0 +1,18 @@ +system_prompt: |- + ### 角色 + 你是平台的通用智能工作台助手,负责准确、安全、高效地帮助用户完成日常知识工作。 + + ### 能力使用 + - 能够依靠自身推理完成时直接回答。 + - 当工具或技能能够显著提高正确性,或任务必须操作外部数据时,使用当前可用的 Tools 和 Skills。 + - 存在子智能体时,只把任务委派给能力描述与任务匹配的智能体;给出清晰、完整的任务说明,并忠实整合返回结果,不得补造缺失内容。 + - 任务包含彼此独立的专业部分时,可以调用多个子智能体,但应避免重复委派和不必要调用。 + - 附件、运行时 Skills、知识库、Tools 和子智能体都是本次请求或会话范围内的能力,不得声称它们永久修改了智能体配置。 + + ### 边界 + - 除非平台明确进入专用创建流程,否则不得创建或修改 Skill 或 Agent。 + - 只有对应 Tool 或子智能体返回成功证据时,才能声称操作成功。 + - 遵守访问控制,不得推断自己拥有当前运行上下文中未提供的资源权限。 + + ### 回答 + 优先给出直接、可用的答案;如存在重要不确定性、能力调用失败或信息缺失,应明确说明。 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6deec3deb0..af8d1f251d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -51,6 +51,8 @@ test = [ "pytest", "pytest-cov", "coverage", + "jsonschema>=4.25.0", + "openpyxl>=3.1.5", "langfuse==2.60.10", "unittest2", "mock", diff --git a/backend/runtime_service.py b/backend/runtime_service.py index 652ab5915a..a4d95bf61c 100644 --- a/backend/runtime_service.py +++ b/backend/runtime_service.py @@ -21,11 +21,11 @@ ) -logging.config.dictConfig(get_uvicorn_logging_config(categories=["runtime"])) +logging.config.dictConfig(get_uvicorn_logging_config(categories=["runtime", "model_call"])) configure_elasticsearch_logging() logger = logging.getLogger("runtime") if __name__ == "__main__": logger.info("Starting server initialization...") logger.info(f"APP version is: {APP_VERSION}") - uvicorn.run(app, host="0.0.0.0", port=5014, log_level="info", log_config=get_uvicorn_logging_config(categories=["runtime"])) + uvicorn.run(app, host="0.0.0.0", port=5014, log_level="info", log_config=get_uvicorn_logging_config(categories=["runtime", "model_call"])) diff --git a/backend/services/agent_evaluation_service.py b/backend/services/agent_evaluation_service.py index d1b55b72f3..450718e52d 100644 --- a/backend/services/agent_evaluation_service.py +++ b/backend/services/agent_evaluation_service.py @@ -23,6 +23,7 @@ from nexent.core.agents.sandbox import _scan_shell_calls from nexent.core.concurrency import ManagedTaskSpec +from consts.const import CAN_EDIT_ALL_USER_ROLES from consts.error_code import ErrorCode from consts.evaluation_limits import ( DEFAULT_PASS_THRESHOLD, @@ -60,6 +61,7 @@ materialize_virtual_evaluation_set_for_run, ) from database.evaluator_db import get_evaluator +from database.user_tenant_db import get_user_tenant_by_user_id from management.services.agent.service import prepare_agent_run from services.evaluation_set_service import resolve_latest_published_version_no from services.thread_lifecycle_service import ( @@ -2180,13 +2182,13 @@ def get_agent_evaluation_run_impl( def list_agent_evaluations_by_agent_impl( - agent_id: int, + agent_ids: list[int], tenant_id: str, limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: return list_agent_evaluations_by_agent( - agent_id=agent_id, tenant_id=tenant_id, limit=limit, offset=offset + agent_ids=agent_ids, tenant_id=tenant_id, limit=limit, offset=offset ) @@ -2327,6 +2329,12 @@ def get_evaluation_stats_impl( } +def _resolve_user_role(user_id: str) -> str: + """Return the caller's tenant role; unknown callers fall back to USER.""" + record = get_user_tenant_by_user_id(user_id) + return str((record or {}).get("user_role") or "USER").upper() + + def delete_agent_evaluation_run_impl( agent_evaluation_id: int, tenant_id: str, @@ -2335,7 +2343,8 @@ def delete_agent_evaluation_run_impl( run = get_agent_evaluation( agent_evaluation_id=agent_evaluation_id, tenant_id=tenant_id ) - if run.get("created_by") != user_id: + is_creator = run.get("created_by") == user_id + if not is_creator and _resolve_user_role(user_id) not in CAN_EDIT_ALL_USER_ROLES: raise AppException(ErrorCode.AGENT_EVALUATION_ONLY_CREATOR_CAN_DELETE) evaluator_config_raw = run.get("evaluator_config") diff --git a/backend/services/agent_reasoning_service.py b/backend/services/agent_reasoning_service.py new file mode 100644 index 0000000000..cbffb58def --- /dev/null +++ b/backend/services/agent_reasoning_service.py @@ -0,0 +1,153 @@ +"""Helpers for keeping agent-owned reasoning settings independent from models.""" + +from copy import deepcopy +from typing import Optional + +from database.model_management_db import get_model_by_model_id +from utils.model_name_utils import add_repo_to_name +from utils.reasoning import normalize_reasoning_params + + +REASONING_EFFORT_VALUES = { + "auto", + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +} +COMMON_REASONING_LEVELS = {"low", "medium", "high"} + + +def _resolve_model_reasoning_capability(model_info: Optional[dict]) -> Optional[dict]: + """Resolve capability metadata for an agent snapshot when needed.""" + if not isinstance(model_info, dict): + return None + capability = model_info.get("reasoning_capability") + if isinstance(capability, dict): + return capability + try: + from configs.model_catalog_loader import resolve_reasoning_capability + except ImportError: + return None + return resolve_reasoning_capability( + model_name=add_repo_to_name( + model_info.get("model_repo", ""), model_info.get("model_name", "") + ), + base_url=model_info.get("base_url"), + provider_hint=model_info.get("model_factory"), + ) + + +def reasoning_snapshot_from_model(model_info: Optional[dict]) -> dict: + """Return the agent-owned reasoning settings copied from a model row.""" + model_extra = (model_info or {}).get("extra_params") + model_extra = model_extra if isinstance(model_extra, dict) else {} + capability = _resolve_model_reasoning_capability(model_info) + model_extra = normalize_reasoning_params(model_extra, capability) + enabled = model_extra.get("enable_thinking") + if not isinstance(enabled, bool): + enabled = False + + snapshot = {"enable_thinking": enabled} + if enabled: + effort = model_extra.get("reasoning_effort") + if isinstance(capability, dict) and capability.get("status") == "supported": + levels = { + str(value) + for control in capability.get("controls") or [] + if isinstance(control, dict) and control.get("type") == "effort" + for value in control.get("values") or [] + } + levels.update(capability.get("levels") or []) + levels = levels or COMMON_REASONING_LEVELS + else: + levels = set() + snapshot["reasoning_effort"] = ( + effort + if effort == "auto" + or (effort in REASONING_EFFORT_VALUES and effort in levels) + else "auto" + ) if levels else None + budget = model_extra.get("reasoning_budget_tokens") + if isinstance(budget, int) and not isinstance(budget, bool) and budget > 0: + controls = capability.get("controls") if isinstance(capability, dict) else None + budget_control = next( + (control for control in controls or [] + if isinstance(control, dict) and control.get("type") == "budget_tokens"), + None, + ) + minimum = budget_control.get("min") if isinstance(budget_control, dict) else None + maximum = budget_control.get("max") if isinstance(budget_control, dict) else None + if isinstance(minimum, int) and isinstance(maximum, int): + snapshot["reasoning_budget_tokens"] = min(maximum, max(minimum, budget)) + snapshot.pop("reasoning_effort", None) + if snapshot.get("reasoning_effort") is None: + snapshot.pop("reasoning_effort", None) + return snapshot + + +def snapshot_agent_reasoning_config( + model_ids: Optional[list[int]], + requested_overrides: Optional[dict], + existing_overrides: Optional[dict], + tenant_id: str, +) -> Optional[dict]: + """Snapshot reasoning settings while preserving explicit agent overrides. + + Only missing reasoning keys are filled from the model. Explicit agent + values always win, so later model edits do not leak into an agent's + configuration. + """ + override_map = deepcopy( + requested_overrides + if requested_overrides is not None + else existing_overrides or {} + ) + if not isinstance(override_map, dict): + override_map = {} + + for model_id in model_ids or []: + model_key = str(model_id) + entry = override_map.get(model_key) + if not isinstance(entry, dict): + entry = {} + else: + entry = deepcopy(entry) + + extra_params = entry.get("extra_params") + extra_params = ( + deepcopy(extra_params) if isinstance(extra_params, dict) else {} + ) + + model_info = get_model_by_model_id(model_id, tenant_id=tenant_id) + capability = _resolve_model_reasoning_capability(model_info) + has_reasoning_override = ( + "enable_thinking" in extra_params + or "reasoning_effort" in extra_params + or "reasoning_budget_tokens" in extra_params + or "reasoning_effort" in entry + ) + if not has_reasoning_override: + extra_params.update(reasoning_snapshot_from_model(model_info)) + extra_params = normalize_reasoning_params(extra_params, capability) + if ( + extra_params.get("enable_thinking") is True + and not any( + key in extra_params + for key in ("reasoning_effort", "reasoning_budget_tokens") + ) + ): + effort_control = any( + isinstance(control, dict) and control.get("type") == "effort" + for control in (capability or {}).get("controls") or [] + ) + if effort_control: + extra_params["reasoning_effort"] = "auto" + + entry["extra_params"] = extra_params + override_map[model_key] = entry + + return override_map or None diff --git a/backend/services/agent_repository_service.py b/backend/services/agent_repository_service.py index 0d96243543..5ba33e76db 100644 --- a/backend/services/agent_repository_service.py +++ b/backend/services/agent_repository_service.py @@ -1,4 +1,5 @@ import logging +import uuid from typing import Any, Collection, Dict, FrozenSet, List, Optional, Tuple from consts.agent_repository import ( @@ -13,12 +14,14 @@ VALID_REPOSITORY_STATUSES, ) from consts.exceptions import UnauthorizedError -from consts.model import AgentRepositorySnapshot, SkillResolution +from consts.const import SYSTEM_TENANT_ID + +from consts.model import AgentRepositorySnapshot, KnowledgeBaseResolution, SkillResolution from consts.notification import ( EVENT_TYPE_REPOSITORY_REVIEW_PENDING, RESOURCE_TYPE_AGENT_REPOSITORY, ) -from database.agent_db import search_agent_info_by_agent_id +from database.agent_db import delete_agent_by_id, search_agent_info_by_agent_id from database.agent_repository_db import ( fetch_draft_agent_mine_metadata, get_agent_repository_by_agent_id, @@ -31,6 +34,7 @@ sum_agent_repository_downloads_by_agent_ids, update_agent_repository_by_id, update_agent_repository_status_by_id, + soft_delete_agent_repository_record, ) from database.agent_version_db import search_version_by_version_no from database.tag_management_db import TagManagementDB @@ -42,6 +46,10 @@ import_agent_with_skills_impl, list_all_agent_info_impl, ) +from management.services.agent.icon_storage import ( + read_icon_image, + upload_icon_image, +) from services.notification_service import ( create_repository_pending_review_notification, create_repository_review_notification, @@ -77,7 +85,64 @@ _MAX_LISTING_TAGS = 5 _MAX_LISTING_TAG_LENGTH = 20 -_MAX_LISTING_ICON_LENGTH = 32 +_MAX_LISTING_ICON_LENGTH = 1024 + + +def _repository_icon_url(agent_id: int, version_no: int, image_id: str) -> str: + return f"/api/repository/agent/{agent_id}/versions/{version_no}/icon/{image_id}" + + +def _repository_icon_object_name( + tenant_id: str, agent_id: int, version_no: int, image_id: str +) -> str: + return f"agent-repository-icons/{tenant_id}/{agent_id}/{version_no}/{image_id}" + + +def _repository_image_id(icon_url: str, agent_id: int, version_no: int) -> str: + prefix = f"/api/repository/agent/{agent_id}/versions/{version_no}/icon/" + if not icon_url.startswith(prefix): + raise ValueError("Invalid repository icon URL") + image_id = icon_url[len(prefix):] + try: + if str(uuid.UUID(image_id)) != image_id: + raise ValueError("Invalid repository icon URL") + except ValueError as exc: + raise ValueError("Invalid repository icon URL") from exc + return image_id + + +async def upload_agent_repository_icon_impl( + agent_id: int, version_no: int, tenant_id: str, user_id: str, content: bytes +) -> Dict[str, str]: + if version_no < 0: + raise ValueError("version_no must be >= 0") + agent_info = search_agent_info_by_agent_id(agent_id, tenant_id, version_no) + if not agent_info: + raise ValueError("Agent version not found") + _validate_create_listing_permission(user_id=user_id, agent_info=agent_info) + image_id = str(uuid.uuid4()) + content_type = upload_icon_image( + content, _repository_icon_object_name(tenant_id, agent_id, version_no, image_id) + ) + return { + "icon_url": _repository_icon_url(agent_id, version_no, image_id), + "content_type": content_type, + } + + +def get_agent_repository_icon_impl( + agent_id: int, version_no: int, image_id: str, tenant_id: str +) -> tuple[bytes, str]: + listing = get_agent_repository_by_agent_id( + agent_id, version_no, publisher_tenant_id=tenant_id + ) + if not listing or listing.get("icon_url") != _repository_icon_url( + agent_id, version_no, image_id + ): + raise FileNotFoundError("Repository icon not found") + return read_icon_image( + _repository_icon_object_name(tenant_id, agent_id, version_no, image_id) + ) def _to_summary_item( @@ -104,9 +169,13 @@ def _to_summary_item( "tags": record.get("tags") or [], "tool_count": record.get("tool_count") or 0, "version_label": record.get("version_name"), - "icon": record.get("icon"), + "version_no": record.get("version_no"), + "create_time": _serialize_created_at(record.get("create_time")), + "icon_url": record.get("icon_url"), "downloads": downloads, "content": record.get("content"), + "publisher_tenant_id": record.get("publisher_tenant_id"), + "is_official": record.get("publisher_tenant_id") == SYSTEM_TENANT_ID, } @@ -203,6 +272,30 @@ def list_agent_repository_listings_impl( status=status, agent_id=agent_id, ) + # Summary queries intentionally omit the publisher field. Restore it + # before mapping the response so official rows can be identified by the + # client and routed to the official copy flow. + for record in records: + record.setdefault("publisher_tenant_id", tenant_id) + # Official listings are published by the reserved official tenant. They + # must remain visible after the standalone deployment command finishes; + # using OFFICIAL_AGENT_PROFILES here would incorrectly couple visibility to + # the Nexent container's startup environment. + if agent_id is None and (status is None or status == STATUS_SHARED): + official_records = list_agent_repository_summaries( + publisher_tenant_id=SYSTEM_TENANT_ID, + status=STATUS_SHARED, + agent_id=agent_id, + ) + for record in official_records: + record["publisher_tenant_id"] = SYSTEM_TENANT_ID + records.extend(official_records) + # Keep the response stable if a repository record is visible through + # both tenant queries (for example during a migration or in tests). + unique_records = {} + for record in records: + unique_records[record.get("agent_repository_id")] = record + records = list(unique_records.values()) if tag_predicates: record_agent_ids = [ str(record["agent_id"]) @@ -317,12 +410,14 @@ def _normalize_listing_tags(tags: Any) -> List[str]: def _validate_card_fields(repository_data: Dict[str, Any]) -> None: """Validate marketplace card fields required for listing submission.""" - icon = repository_data.get("icon") - if not icon or not isinstance(icon, str) or not icon.strip(): - raise ValueError("icon is required and must be a non-empty string") - if len(icon.strip()) > _MAX_LISTING_ICON_LENGTH: + icon_url = repository_data.get("icon_url") + if icon_url is not None and ( + not isinstance(icon_url, str) + or not icon_url.strip() + or len(icon_url) > _MAX_LISTING_ICON_LENGTH + ): raise ValueError( - f"icon must be at most {_MAX_LISTING_ICON_LENGTH} characters" + f"icon_url must be a non-empty URL up to {_MAX_LISTING_ICON_LENGTH} characters" ) tags = repository_data.get("tags") @@ -743,9 +838,13 @@ def get_agent_repository_listing_detail_impl( agent_repository_id, tenant_id, ) + if not record: + record = get_agent_repository_by_id( + agent_repository_id, + SYSTEM_TENANT_ID, + ) if not record: raise ValueError("Repository listing not found") - root_agent = _extract_root_agent_from_snapshot(record.get("agent_info_json")) agent_id = record.get("agent_id") download_total = 0 @@ -758,12 +857,13 @@ def get_agent_repository_listing_detail_impl( return { "agent_repository_id": record.get("agent_repository_id"), "agent_id": agent_id, + "version_no": record.get("version_no"), "name": record.get("name"), "display_name": record.get("display_name"), "description": record.get("description"), "author": record.get("author"), "submitted_by": record.get("submitted_by"), - "icon": record.get("icon"), + "icon_url": record.get("icon_url"), "status": record.get("status"), "version_label": record.get("version_name"), "downloads": download_total, @@ -771,6 +871,7 @@ def get_agent_repository_listing_detail_impl( "model_name": root_agent.get("model_name"), "duty_prompt": root_agent.get("duty_prompt"), "tools": _extract_tool_names(root_agent), + "is_official": record.get("publisher_tenant_id") == SYSTEM_TENANT_ID, } @@ -874,6 +975,11 @@ def update_agent_repository_status_impl( agent_repository_id, tenant_id, ) + if not record: + record = get_agent_repository_by_id( + agent_repository_id, + SYSTEM_TENANT_ID, + ) if not record: raise ValueError("Repository listing not found") @@ -994,7 +1100,7 @@ def _to_list_item(record: Dict[str, Any]) -> Dict[str, Any]: "tags": record.get("tags") or [], "tool_count": record.get("tool_count"), "version_label": record.get("version_name"), - "icon": record.get("icon"), + "icon_url": record.get("icon_url"), "downloads": record.get("downloads") or 0, "status": record.get("status"), "version_no": record.get("version_no"), @@ -1113,8 +1219,10 @@ async def _build_repository_data_from_agent( } if card_fields: - for key in ("icon", "downloads", "tool_count", "content"): - if key in card_fields and card_fields[key] is not None: + for key in ("icon_url", "downloads", "tool_count", "content"): + if key in card_fields and ( + key == "icon_url" or card_fields[key] is not None + ): repository_data[key] = card_fields[key] if "tags" in card_fields and card_fields["tags"] is not None: repository_data["tags"] = _normalize_listing_tags(card_fields["tags"]) @@ -1136,7 +1244,7 @@ async def create_agent_repository_listing_impl( then inserts or updates the marketplace table. When a listing for the same agent version already exists, its status is - updated to pending_review along with icon and tags when provided. + updated to pending_review along with icon_url and tags when provided. """ if version_no < 0: raise ValueError("version_no must be >= 0") @@ -1150,6 +1258,15 @@ async def create_agent_repository_listing_impl( ) repository_data["content"] = (card_fields or {}).get("content") or "" _validate_create_payload(repository_data) + icon_url = repository_data.get("icon_url") + if icon_url is not None: + image_id = _repository_image_id(icon_url, agent_id, version_no) + try: + read_icon_image( + _repository_icon_object_name(tenant_id, agent_id, version_no, image_id) + ) + except FileNotFoundError as exc: + raise ValueError("Repository icon upload not found") from exc existing = get_agent_repository_by_agent_id( agent_id, @@ -1169,7 +1286,7 @@ async def create_agent_repository_listing_impl( "status": STATUS_PENDING_REVIEW, "content": repository_data["content"], } - for key in ("icon", "tags", "tool_count"): + for key in ("icon_url", "tags", "tool_count"): if key in repository_data: updates[key] = repository_data[key] affected = update_agent_repository_by_id( @@ -1218,12 +1335,41 @@ def check_repository_import_precheck_impl( agent_repository_id, tenant_id, ) + if not record: + record = get_agent_repository_by_id( + agent_repository_id, + SYSTEM_TENANT_ID, + ) if not record: raise ValueError("Repository listing not found") - if record.get("status") != STATUS_SHARED: raise ValueError("Repository listing is not available for import") + if record.get("publisher_tenant_id") == SYSTEM_TENANT_ID: + # Re-read the mounted bundle so the precheck sees the same Skill, + # MCP, model and logical KB declarations that the official installer + # will use. The repository snapshot is intentionally not treated as + # the source of official seed documents. + from services.official_agent_service import _load_bundle + + bundle_name = str(record.get("name") or "") + bundle = _load_bundle(bundle_name) + if bundle is None: + raise ValueError(f"Official agent bundle not found: {bundle_name}") + display_name = ( + str(record.get("display_name") or "").strip() + or str(record.get("name") or "").strip() + or "Agent" + ) + result = build_repository_import_precheck( + agent_repository_id=agent_repository_id, + display_name=display_name, + snapshot=bundle, + tenant_id=tenant_id, + require_kb_embedding_model=True, + ) + return result.model_dump() + agent_info_json = record.get("agent_info_json") if not isinstance(agent_info_json, dict): raise ValueError("Repository listing has no agent snapshot") @@ -1248,15 +1394,74 @@ async def import_agent_from_repository_impl( tenant_id: str, authorization: str, skill_resolutions: Optional[List[SkillResolution]] = None, -) -> Dict[int, int]: + model_ids: Optional[Dict[str, int]] = None, + embedding_model_ids: Optional[Dict[str, int]] = None, + knowledge_base_resolutions: Optional[List[KnowledgeBaseResolution]] = None, + user_id: Optional[str] = None, + return_root_id: bool = False, +) -> Dict[int, int] | Dict[str, int]: """Import an agent tree from a marketplace repository listing into the current tenant.""" record = get_agent_repository_by_id( agent_repository_id, tenant_id, ) + if not record: + record = get_agent_repository_by_id( + agent_repository_id, + SYSTEM_TENANT_ID, + ) if not record: raise ValueError("Repository listing not found") + # Official listings are templates backed by a mounted bundle. Their + # knowledge bases, skills and MCP servers must be prepared in the target + # tenant before the normal agent snapshot is imported. Ordinary listings + # continue through the existing import path below. + if record.get("publisher_tenant_id") == SYSTEM_TENANT_ID: + from services.official_agent_service import install_official_agents + + # The official bundle directory name is the root Agent name, which is + # already stored in the repository record's ``name`` field. + bundle_name = str(record.get("name") or "") + logger.info( + "Repository import resolved official listing id=%s name=%r " + "publisher_tenant_id=%r target_tenant_id=%r", + agent_repository_id, + bundle_name, + record.get("publisher_tenant_id"), + tenant_id, + ) + results = await install_official_agents( + [bundle_name], + tenant_id=tenant_id, + user_id=user_id or "repository-import", + authorization=authorization, + model_ids=model_ids, + embedding_model_ids=embedding_model_ids, + skill_resolutions=skill_resolutions, + knowledge_base_resolutions=knowledge_base_resolutions, + ) + item = results[0] if results else None + if item is None: + raise ValueError("Official agent installation returned no result") + if item.status == "needs_model": + raise ValueError(item.message or "Official agent requires model configuration") + if item.status == "failed": + raise ValueError(item.message or "Official agent installation failed") + if item.status == "not_found": + raise ValueError(item.message or "Official agent bundle not found") + + affected = increment_agent_repository_downloads(agent_repository_id) + if affected == 0: + logger.warning( + "Failed to increment repository downloads after official import " + "(agent_repository_id=%s)", + agent_repository_id, + ) + if return_root_id: + return {"agent_id": item.agent_id} if item.agent_id else {} + return {record.get("agent_id"): item.agent_id} if item.agent_id else {} + agent_info_json = record.get("agent_info_json") if not isinstance(agent_info_json, dict): raise ValueError("Repository listing has no agent snapshot") @@ -1284,4 +1489,74 @@ async def import_agent_from_repository_impl( "(agent_repository_id=%s)", agent_repository_id, ) + if return_root_id: + return {"agent_id": result[snapshot.agent_id]} return result + + +def list_official_agent_management_impl() -> List[Dict[str, Any]]: + """Return active official listings for super-admin management.""" + records = list_agent_repository_summaries( + publisher_tenant_id=SYSTEM_TENANT_ID, + status=STATUS_SHARED, + ) + return [ + { + **record, + "publisher_tenant_id": SYSTEM_TENANT_ID, + } + for record in records + ] + + +def delete_official_agent_impl(agent_repository_id: int, user_id: str) -> Dict[str, Any]: + """Delete an official template and source bundle, preserving tenant copies.""" + import os + import shutil + from consts.const import OFFICIAL_AGENTS_PATH + + record = get_agent_repository_by_id(agent_repository_id, SYSTEM_TENANT_ID) + if not record: + raise ValueError("Official agent repository listing not found") + bundle_name = str(record.get("name") or "").strip() + if not bundle_name or bundle_name in {".", ".."} or "/" in bundle_name or "\\" in bundle_name: + raise ValueError("Official agent bundle name is invalid") + root = os.path.realpath(OFFICIAL_AGENTS_PATH) + candidates: list[str] = [] + direct_dir = os.path.join(root, bundle_name) + if os.path.isdir(direct_dir): + candidates.append(direct_dir) + for current_root, directories, files in os.walk(root): + directories[:] = [item for item in directories if not item.startswith(".")] + if os.path.basename(current_root) == bundle_name and "agent.json" in files: + candidates.append(current_root) + for filename in files: + if filename in {f"{bundle_name}.json", f"{bundle_name}.zip"}: + candidates.append(os.path.join(current_root, filename)) + deleted_paths: list[str] = [] + for candidate in dict.fromkeys(candidates): + resolved = os.path.realpath(candidate) + if resolved == root or not resolved.startswith(root + os.sep): + raise ValueError("Official agent bundle path escapes configured root") + if os.path.isdir(resolved): + shutil.rmtree(resolved) + elif os.path.isfile(resolved): + os.remove(resolved) + deleted_paths.append(candidate) + snapshot = record.get("agent_info_json") or {} + for source_id in (snapshot.get("agent_info") or {}).keys(): + if str(source_id).isdigit(): + delete_agent_by_id(int(source_id), SYSTEM_TENANT_ID, user_id) + affected = soft_delete_agent_repository_record( + agent_repository_id, + publisher_tenant_id=SYSTEM_TENANT_ID, + user_id=user_id, + ) + if affected == 0: + raise ValueError("Official agent repository listing was already deleted") + return { + "agent_repository_id": agent_repository_id, + "name": bundle_name, + "deleted_bundle_paths": deleted_paths, + "preserved_tenant_copies": True, + } diff --git a/backend/services/agent_version_service.py b/backend/services/agent_version_service.py index 67b75de8e8..1025bbc643 100644 --- a/backend/services/agent_version_service.py +++ b/backend/services/agent_version_service.py @@ -32,12 +32,29 @@ STATUS_ARCHIVED, ) from database.model_management_db import get_model_by_model_id, get_valid_model_ids +from database.agent_db import is_system_agent from utils.str_utils import convert_string_to_list from consts.agent_unavailable_reasons import AgentUnavailableReason logger = logging.getLogger("agent_version_service") +def _ensure_system_agent_mutation_allowed( + agent_id: int, + tenant_id: str, + allow_system: bool = False, +) -> None: + """Reject public mutations of protected platform Agents.""" + if not allow_system and is_system_agent(agent_id, tenant_id) is True: + raise ValueError("System Agent is managed by the platform") + + +def _ensure_system_agent_hidden(agent_id: int, tenant_id: str) -> None: + """Hide protected platform Agents from ordinary direct-ID read paths.""" + if is_system_agent(agent_id, tenant_id) is True: + raise ValueError("Agent not found") + + def _remove_audit_fields_for_insert(data: dict) -> None: """ Remove audit fields that should not be copied during snapshot @@ -66,6 +83,7 @@ def publish_version_impl( release_note: Optional[str] = None, source_type: str = SOURCE_TYPE_NORMAL, source_version_no: Optional[int] = None, + allow_system: bool = False, ) -> dict: """ Publish a new version @@ -74,6 +92,8 @@ def publish_version_impl( 3. Update current_version_no 4. Optionally register as A2A Server agent """ + _ensure_system_agent_mutation_allowed(agent_id, tenant_id, allow_system) + # Get draft data agent_draft, tools_draft, relations_draft = query_agent_draft(agent_id, tenant_id) if not agent_draft: @@ -224,6 +244,7 @@ def get_version_list_impl( """ Get version list for an agent """ + _ensure_system_agent_hidden(agent_id, tenant_id) items = query_version_list( agent_id=agent_id, tenant_id=tenant_id, @@ -243,7 +264,11 @@ def get_version_impl( """ Get version """ - return search_version_by_version_no(agent_id, tenant_id, version_no) + _ensure_system_agent_hidden(agent_id, tenant_id) + version = search_version_by_version_no(agent_id, tenant_id, version_no) + if not version: + raise ValueError(f"Version {version_no} not found") + return version def get_version_detail_impl( @@ -255,6 +280,7 @@ def get_version_detail_impl( Get version detail including snapshot data, structured like agent info. Returns agent info with tools, sub_agents, skills, availability, etc. """ + _ensure_system_agent_hidden(agent_id, tenant_id) result: Dict[str, Any] = {} # Get version metadata first @@ -400,6 +426,8 @@ def rollback_version_impl( Returns: Success message with target version info """ + _ensure_system_agent_mutation_allowed(agent_id, tenant_id) + # Verify the target version exists version = search_version_by_version_no(agent_id, tenant_id, target_version_no) if not version: @@ -452,6 +480,7 @@ def update_version_status_impl( """ Update version status (DISABLED / ARCHIVED) """ + _ensure_system_agent_mutation_allowed(agent_id, tenant_id) valid_statuses = [STATUS_DISABLED, STATUS_ARCHIVED] if status not in valid_statuses: raise ValueError(f"Invalid status. Must be one of: {valid_statuses}") @@ -481,6 +510,8 @@ def update_version_impl( """ Update version metadata (version_name and release_note) """ + _ensure_system_agent_mutation_allowed(agent_id, tenant_id) + # Check if version exists version = search_version_by_version_no(agent_id, tenant_id, version_no) if not version: @@ -514,6 +545,8 @@ def delete_version_impl( Soft delete a version by setting delete_flag='Y' Also soft deletes all related snapshot data (agent, tools, relations, skills) for this version """ + _ensure_system_agent_mutation_allowed(agent_id, tenant_id) + # Check if version exists version = search_version_by_version_no(agent_id, tenant_id, version_no) if not version: @@ -584,6 +617,7 @@ def get_current_version_impl( """ Get current published version """ + _ensure_system_agent_hidden(agent_id, tenant_id) current_version_no = query_current_version_no(agent_id, tenant_id) if current_version_no is None: raise ValueError("No published version") @@ -615,6 +649,7 @@ def compare_versions_impl( Returns detailed comparison data for both versions. Handles version 0 as draft data. """ + _ensure_system_agent_hidden(agent_id, tenant_id) # Get version A detail (handles version 0 as draft) version_a = _get_version_detail_or_draft(agent_id, tenant_id, version_no_a) # Get version B detail (handles version 0 as draft) @@ -725,6 +760,7 @@ def _get_version_detail_or_draft( Get version detail for published versions, or draft data for version 0. Returns structured agent info similar to get_version_detail_impl. """ + _ensure_system_agent_hidden(agent_id, tenant_id) from database import skill_db as skill_db_module result: Dict[str, Any] = {} @@ -849,6 +885,8 @@ async def list_published_agents_impl( enriched_agents: list[dict] = [] for agent in agent_list: + if agent.get("agent_origin") == "SYSTEM" or agent.get("system_key"): + continue # Filter out disabled agents if not agent.get("enabled"): continue @@ -998,9 +1036,22 @@ async def list_published_agents_impl( "greeting_message": agent.get("greeting_message"), "example_questions": agent.get("example_questions"), "allow_chat_metadata": bool(agent.get("allow_chat_metadata", False)), + "model_params_override": agent.get("model_params_override"), + "enable_protocol_repair_retry": agent.get("enable_protocol_repair_retry") is True, }) - return simple_agent_list + try: + from services.resource_tag_projection import project_authorized_resource_tags + + return project_authorized_resource_tags( + simple_agent_list, + resource_type="agent", + id_field="agent_id", + default_tenant_id=tenant_id, + ) + except Exception as error: # noqa: BLE001 - tags are display-only list metadata + logger.warning("Failed to project published Agent tags: %s", error) + return [{**agent, "tags": []} for agent in simple_agent_list] except Exception as e: logger.error(f"Failed to list published agents: {str(e)}") diff --git a/backend/services/audit_service.py b/backend/services/audit_service.py new file mode 100644 index 0000000000..09e90b5f3d --- /dev/null +++ b/backend/services/audit_service.py @@ -0,0 +1,128 @@ +"""Security audit logging for successful sensitive operations. + +Every entry is a single line prefixed with ``[SEC_AUDIT]`` and emitted through +the ``audit.security`` logger, which propagates to the root logger and lands in +the serving process' per-category log file (e.g. ``logs/config/nexent_config.log`` +for the config service) without introducing a new log category:: + + [SEC_AUDIT] event=user_signin result=success user_id=u1 tenant_id=t1 email=a@b.com ip=1.2.3.4 ua="Mozilla/5.0" session_id=- details={} + +Only successful operations are recorded: a failing operation is treated as an +interception and stays in the existing business error logs. Recording is +best-effort: ``record_security_event`` never raises, so an audit failure can +never break the underlying business flow. Secrets (passwords, access keys, +tokens) must never be passed in; call sites only provide identifiers and +non-sensitive context. +""" + +import json +import logging +from typing import Any, Dict, Optional + +from fastapi import Request + +logger = logging.getLogger("audit.security") + +AUDIT_LOG_PREFIX = "[SEC_AUDIT]" +USER_AGENT_MAX_LENGTH = 200 + +AUDIT_RESULT_SUCCESS = "success" + + +def get_client_ip(request: Optional[Request]) -> str: + """Resolve the client IP: first hop of X-Forwarded-For, then X-Real-IP, then peer. + + X-Forwarded-For is attacker-controllable, so the result is a lead for + investigation, not proof of origin. + """ + if request is None: + return "" + forwarded_for = request.headers.get("x-forwarded-for", "") + if forwarded_for: + first_hop = forwarded_for.split(",")[0].strip() + if first_hop: + return first_hop + real_ip = request.headers.get("x-real-ip", "").strip() + if real_ip: + return real_ip + return request.client.host if request.client else "" + + +def _compact(value: Any) -> str: + """Flatten a value into a whitespace-free string so the entry stays one log line.""" + if value is None: + return "" + return "".join(str(value).split()) + + +def format_audit_entry( + event_type: str, + user_id: str = "", + tenant_id: str = "", + user_email: str = "", + client_ip: str = "", + user_agent: str = "", + session_id: str = "", + details: Optional[Dict[str, Any]] = None, + result: str = AUDIT_RESULT_SUCCESS, +) -> str: + """Render one audit entry as a single log line. + + Atomic fields are whitespace-free; user_agent is quoted (inner quotes + replaced); details is a whitespace-collapsed JSON blob kept last so a + parser can treat the rest of the line as its value. + """ + ua = " ".join(str(user_agent).split()).replace('"', "'")[:USER_AGENT_MAX_LENGTH] + if details: + try: + details_json = " ".join( + json.dumps(details, ensure_ascii=False, separators=(",", ":"), default=str).split() + ) + except Exception: + details_json = "unserializable" + else: + details_json = "-" + + parts = [ + f"event={_compact(event_type) or '-'}", + f"result={_compact(result) or '-'}", + f"user_id={_compact(user_id) or '-'}", + f"tenant_id={_compact(tenant_id) or '-'}", + f"email={_compact(user_email) or '-'}", + f"ip={_compact(client_ip) or '-'}", + f'ua="{ua}"' if ua else "ua=-", + f"session_id={_compact(session_id) or '-'}", + f"details={details_json}", + ] + return " ".join(parts) + + +def record_security_event( + event_type: str, + request: Optional[Request] = None, + user_id: Optional[str] = None, + tenant_id: Optional[str] = None, + user_email: Optional[str] = None, + session_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + result: str = AUDIT_RESULT_SUCCESS, +) -> None: + """Record one security audit entry for a successful operation. Never raises.""" + try: + entry = format_audit_entry( + event_type=event_type, + result=result, + user_id=str(user_id) if user_id else "", + tenant_id=str(tenant_id) if tenant_id else "", + user_email=str(user_email) if user_email else "", + client_ip=get_client_ip(request), + user_agent=request.headers.get("user-agent", "") if request else "", + session_id=str(session_id) if session_id else "", + details=details, + ) + logger.info("%s %s", AUDIT_LOG_PREFIX, entry) + except Exception: + try: + logger.error("Failed to record security audit entry, event=%s", event_type) + except Exception: + pass diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 86ac3c650d..9f80f2d285 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -7,10 +7,11 @@ from jinja2 import StrictUndefined, Template from nexent.core.concurrency import run_blocking +from nexent.monitor import get_monitoring_manager, set_monitoring_context, set_monitoring_operation from consts.const import LANGUAGE, MODEL_CONFIG_MAPPING, MESSAGE_ROLE, DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE from consts.model import AgentRequest, MessageRequest, MessageUnit -from consts.exceptions import ConversationNotFoundError, ValidationError +from consts.exceptions import AppException, ConversationNotFoundError, ValidationError from database.conversation_db import ( CHAT_MODE_VALUES, create_conversation, @@ -38,6 +39,8 @@ update_conversation_agent_id, update_conversation_chat_mode, update_conversation_knowledge_scope, + replace_conversation_workbench_config, + replace_conversation_workbench_and_metadata, update_conversation_message_content, update_conversation_message_status, update_message_minio_files, @@ -46,13 +49,13 @@ update_message_unit_status, ) from database.model_management_db import get_model_by_model_id -from nexent.monitor import set_monitoring_context, set_monitoring_operation from services.model_gateway_service import get_llm_adapter_from_config from utils.config_utils import tenant_config_manager from utils.prompt_template_utils import get_generate_title_prompt_template from utils.str_utils import remove_think_blocks logger = logging.getLogger("conversation_management_service") +monitoring_manager = get_monitoring_manager() def save_message(request: MessageRequest, user_id: str, tenant_id: str, @@ -402,6 +405,7 @@ def create_new_conversation( chat_mode: Optional[str] = None, knowledge_scope: Optional[Dict[str, Any]] = None, runtime_metadata: Optional[Dict[str, Any]] = None, + workbench_config: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Create a new conversation @@ -424,13 +428,61 @@ def create_new_conversation( create_kwargs["knowledge_scope"] = knowledge_scope if runtime_metadata is not None: create_kwargs["runtime_metadata"] = runtime_metadata + if workbench_config is not None: + create_kwargs["workbench_config"] = workbench_config conversation_data = create_conversation(title, user_id, **create_kwargs) return conversation_data + except AppException: + raise except Exception as e: logging.error(f"Failed to create conversation: {str(e)}") raise Exception(str(e)) +def update_conversation_workbench_config_service( + conversation_id: int, + config: Dict[str, Any], + expected_version: int, + user_id: str, + only_if_changed: bool = False, +) -> Dict[str, Any]: + """Validate and atomically replace a conversation Workbench declaration.""" + + from consts.model import WorkbenchSessionConfig + + normalized = WorkbenchSessionConfig.model_validate(config).model_dump(mode="json") + return replace_conversation_workbench_config( + conversation_id=conversation_id, + user_id=user_id, + config=normalized, + expected_version=expected_version, + only_if_changed=only_if_changed, + ) + + +def update_conversation_workbench_and_metadata_service( + conversation_id: int, + config: Dict[str, Any], + expected_config_version: int, + metadata: Dict[str, Any], + expected_metadata_version: Optional[int], + user_id: str, +) -> Dict[str, Any]: + """Validate Workbench config and commit it with runtime metadata atomically.""" + + from consts.model import WorkbenchSessionConfig + + normalized = WorkbenchSessionConfig.model_validate(config).model_dump(mode="json") + return replace_conversation_workbench_and_metadata( + conversation_id=conversation_id, + user_id=user_id, + config=normalized, + expected_config_version=expected_config_version, + metadata=metadata, + expected_metadata_version=expected_metadata_version, + ) + + def get_conversation_service( conversation_id: int, user_id: str, @@ -511,6 +563,7 @@ def update_conversation_knowledge_scope_service( knowledge_scope: Optional[Dict[str, Any]], user_id: str, tenant_id: str, + expected_workbench_config_version: Optional[int] = None, ) -> Dict[str, Any]: """Validate, preview, and replace a user-owned conversation knowledge scope.""" conversation = get_conversation( @@ -522,6 +575,24 @@ def update_conversation_knowledge_scope_service( raise ConversationNotFoundError( f"Conversation {conversation_id} does not exist or is not accessible" ) + canonical = conversation.get("workbench_config") + if isinstance(canonical, dict): + from services.workbench_service import assert_workbench_version + + assert_workbench_version(conversation, expected_workbench_config_version) + next_config = {**canonical, "knowledge_scope": knowledge_scope} + updated = update_conversation_workbench_config_service( + conversation_id=conversation_id, + config=next_config, + expected_version=expected_workbench_config_version, + user_id=user_id, + ) + return { + **updated, + "desired_scope": knowledge_scope, + "effective_preview": None, + "warnings": [], + } effective_preview = None warnings: List[Dict[str, Any]] = [] if knowledge_scope is not None and conversation.get("agent_id") is not None: @@ -926,6 +997,8 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List 'knowledge_scope': history_data.get('knowledge_scope'), 'runtime_metadata': history_data.get('runtime_metadata') or {}, 'runtime_metadata_version': int(history_data.get('runtime_metadata_version') or 0), + 'workbench_config': history_data.get('workbench_config'), + 'workbench_config_version': int(history_data.get('workbench_config_version') or 0), 'create_time': history_data['create_time'], 'message': messages } @@ -1045,6 +1118,7 @@ def get_sources_service(conversation_id: Optional[int], message_id: Optional[int } +@monitoring_manager.monitor_endpoint("conversation.generate_title", include_params=False) async def generate_conversation_title_service(conversation_id: int, question: str, user_id: str, tenant_id: str, language: str = LANGUAGE["ZH"], model_id: Optional[int] = None) -> str: @@ -1065,6 +1139,12 @@ async def generate_conversation_title_service(conversation_id: int, question: st Returns: str: Generated title """ + monitoring_manager.set_span_attributes(**monitoring_manager.build_openinference_attributes( + span_kind="CHAIN", + input_value=question, + session_id=conversation_id, + attributes={"langfuse.trace.name": "生成会话标题", "tenant.id": tenant_id}, + )) try: # Call LLM to generate title from question in a separate thread to avoid blocking title = await run_blocking( @@ -1080,6 +1160,7 @@ async def generate_conversation_title_service(conversation_id: int, question: st # Update conversation title update_conversation_title(conversation_id, title, user_id) + monitoring_manager.set_openinference_output(title) return title diff --git a/backend/services/evaluation_set_service.py b/backend/services/evaluation_set_service.py index 61fe6476c9..eb39165146 100644 --- a/backend/services/evaluation_set_service.py +++ b/backend/services/evaluation_set_service.py @@ -6,6 +6,12 @@ from datetime import datetime, timezone from typing import Any +from consts.const import ( + AIDP_API_KEY, + AIDP_SERVER_URL, + AIDP_TENANT_ID, + ENABLE_AIDP_KNOWLEDGE, +) from consts.error_code import ErrorCode from consts.evaluation_limits import MAX_CASES_PER_SET from consts.evaluation_status import EvalRunStatus @@ -473,11 +479,13 @@ def batch_delete_evaluation_set_cases_impl(evaluation_set_id, case_ids, tenant_i def _resolve_kb_info(kb_names, tenant_id): resolved = [] for name in kb_names: - idx = get_index_name_by_knowledge_name(name, tenant_id) + try: + idx = get_index_name_by_knowledge_name(name, tenant_id) + except ValueError: + logger.warning("KB not found: '%s' for tenant %s", name, tenant_id) + continue if idx: resolved.append({"display_name": name, "index_name": idx}) - else: - logger.warning("KB not found: '%s' for tenant %s", name, tenant_id) return resolved @@ -499,8 +507,9 @@ def _build_kb_descriptions(kb_info, tenant_id): return "\n".join(lines) if lines else "" -def _plan_search_queries(kb_info, description, model_id, tenant_id): - kb_desc_block = _build_kb_descriptions(kb_info, tenant_id) +def _plan_search_queries(kb_info, description, model_id, tenant_id, kb_desc_block=None): + if kb_desc_block is None: + kb_desc_block = _build_kb_descriptions(kb_info, tenant_id) if not kb_desc_block: return [] user_prompt = ( @@ -554,7 +563,9 @@ def _get_kb_embedding_model(tenant_id: str, kb: dict) -> Any: Returns ``None`` (with a warning log) when the model is unavailable. """ - from management.services.knowledge_base.service import get_embedding_model_by_index_name + from management.services.knowledge_base.service import ( + get_embedding_model_by_index_name, + ) try: embedding_model, _, _ = get_embedding_model_by_index_name( @@ -638,6 +649,124 @@ def _format_kb_hit(hit: dict, query: str) -> str: return f"- [{query}] (score={normalized:.2f}) {content.strip()[:400]}" +# ── AIDP KB-aware helpers ──────────────────────────────────────────── + + +def _build_aidp_kb_descriptions(kb_info): + lines = [] + for kb in kb_info: + desc = (kb.get("description") or "").strip() + desc_text = f" - {desc}" if desc else " (no description)" + lines.append(f"- {kb['display_name']}{desc_text}") + return "\n".join(lines) if lines else "" + + +def _resolve_aidp_kb_info(kb_ids, user_id, tenant_id): + """Resolve requested AIDP kds_ids to the KBs the user can access. + + AIDP knowledge bases do not live in ``knowledge_info`` nor in local ES, + so resolution goes through the AIDP access snapshot, which intersects + the remote catalog with the caller's permissions. Requested ids the + user cannot access are dropped (never silently passed upstream). + + A catalog fetch failure degrades to an empty result (warning logged) so + the generation run continues on scene/agent context alone, matching the + ES branch's tolerance for a broken KB backend. + """ + from ext_components.aidp.services.aidp_access_service import ( + resolve_current_aidp_access, + ) + + wanted = {str(kb_id) for kb_id in kb_ids if str(kb_id).strip()} + if not wanted: + return [] + try: + snapshot = resolve_current_aidp_access( + server_url=AIDP_SERVER_URL, + api_key=AIDP_API_KEY, + user_id=user_id, + tenant_id=tenant_id, + aidp_tenant_id=AIDP_TENANT_ID, + ) + except Exception as exc: # noqa: BLE001 — catalog outage must not fail the run + logger.warning( + "AIDP KB catalog unavailable for user %s, generating without KB context: %s", + user_id, + exc, + ) + return [] + resolved = [] + for row in snapshot.accessible_rows: + kds_id = str(row.get("kb_id") or row.get("kds_id") or "") + if kds_id not in wanted: + continue + resolved.append( + { + "kds_id": kds_id, + "display_name": row.get("kds_name") or row.get("name") or kds_id, + "description": row.get("description") or "", + } + ) + missing = wanted - {kb["kds_id"] for kb in resolved} + if missing: + logger.warning( + "AIDP KBs not accessible for user %s, skipped: %s", user_id, sorted(missing) + ) + return resolved + + +def _format_aidp_hit(record: dict, query: str) -> str: + """Format one AIDP FusionSearch record as a bullet line. + + AIDP scores are already 0-1 similarities, unlike ES script_score output, + so they are used as-is. Returns ``""`` when the record has no text. + """ + text = str(record.get("text") or "") + if not text.strip(): + return "" + try: + score = float(record.get("score") or 0.0) + except (TypeError, ValueError): + score = 0.0 + return f"- [{query}] (score={score:.2f}) {text.strip()[:400]}" + + +def _execute_aidp_searches(kb_info, queries, tenant_id, top_k=3): + """Run AIDP FusionSearch for the planned queries across all selected KBs. + + One call per query carries every requested kds_id (the API accepts a + list), so the upstream call count stays at len(queries). Returns the + formatted hit lines, or ``""`` when nothing usable was retrieved. + """ + from ext_components.aidp.services.aidp_service import fusion_search_impl + + if not kb_info or not queries: + return "" + + kds_ids = [kb["kds_id"] for kb in kb_info] + parts: list[str] = [] + for query in queries: + try: + records = fusion_search_impl( + server_url=AIDP_SERVER_URL, + api_key=AIDP_API_KEY, + tenant_id=AIDP_TENANT_ID, + query=query, + kds_list=kds_ids, + top_k=top_k, + ) + except Exception as exc: # noqa: BLE001 — one bad query must not fail generation + logger.warning("AIDP search failed for query '%s': %s", query, exc) + continue + parts.extend( + line + for record in records + for line in [_format_aidp_hit(record, query)] + if line + ) + return "\n".join(parts) + + def _update_generation_status(set_id, tenant_id, status, progress=0): try: with get_db_session() as s: @@ -656,22 +785,47 @@ def _update_generation_status(set_id, tenant_id, status, progress=0): # ── AI case generation (shared helpers) ────────────────────────────── -def _do_kb_search(knowledge_base_names, description, model_id, tenant_id) -> str: - """Resolve KBs → plan queries → execute searches. Returns KB context text.""" +def _do_kb_search(knowledge_base_names, description, model_id, tenant_id, user_id=None): + """Resolve KBs → plan queries → execute searches. + + With ``ENABLE_AIDP_KNOWLEDGE`` the request carries AIDP kds_ids and + retrieval goes through the AIDP FusionSearch API; otherwise the names are + ES knowledge-base display names searched directly in Elasticsearch. + + Returns ``(kb_context, resolved_display_names)`` — the resolved names let + the prompt fallback show human-readable KB names instead of raw kds_ids. + """ if not knowledge_base_names: - return "" - kb_info = _resolve_kb_info(knowledge_base_names, tenant_id) - if not kb_info: - return "" - queries = _plan_search_queries(kb_info, description, model_id, tenant_id) - if not queries: - return "" - kb_context = _execute_kb_searches(kb_info, queries, tenant_id) + return "", [] + if ENABLE_AIDP_KNOWLEDGE: + kb_info = _resolve_aidp_kb_info(knowledge_base_names, user_id, tenant_id) + display_names = [kb["display_name"] for kb in kb_info] + if not kb_info: + return "", [] + queries = _plan_search_queries( + kb_info, + description, + model_id, + tenant_id, + kb_desc_block=_build_aidp_kb_descriptions(kb_info), + ) + if not queries: + return "", display_names + kb_context = _execute_aidp_searches(kb_info, queries, tenant_id) + else: + kb_info = _resolve_kb_info(knowledge_base_names, tenant_id) + display_names = [kb["display_name"] for kb in kb_info] + if not kb_info: + return "", [] + queries = _plan_search_queries(kb_info, description, model_id, tenant_id) + if not queries: + return "", display_names + kb_context = _execute_kb_searches(kb_info, queries, tenant_id) if kb_context: logger.info("KB search returned %d chars", len(kb_context)) else: logger.warning("KB search returned no results") - return kb_context + return kb_context, display_names def _build_agent_context_block(agent_id, tenant_id) -> str: @@ -696,7 +850,13 @@ def _build_agent_context_block(agent_id, tenant_id) -> str: def _format_kb_name(name: str, tenant_id: str) -> str: - """Format a single KB name with its description (truncated to 150 chars).""" + """Format a single KB name with its description (truncated to 150 chars). + + AIDP knowledge-base ids have no ``knowledge_info`` record, so they are + returned as-is (the id carries no user-facing description locally). + """ + if ENABLE_AIDP_KNOWLEDGE: + return name info = _resolve_kb_info([name], tenant_id) if info and info[0].get("description"): return f"{name}({info[0]['description'][:150]})" @@ -724,10 +884,14 @@ def _build_case_gen_context_blocks( description, kb_context, knowledge_base_names, - file_content, - file_name, + resolved_kb_names=None, ): - """Build prompt context blocks for case generation. Order: Agent → Scene → KB → File.""" + """Build prompt context blocks for case generation. Order: Agent → Scene → KB. + + ``resolved_kb_names`` carries the human-readable names resolved from the + request; the KB fallback block prefers it over the raw request values so + AIDP kds_ids never surface verbatim in a prompt. + """ context_blocks: list[str] = [] agent_block = _build_agent_context_block(agent_id, tenant_id) @@ -736,17 +900,19 @@ def _build_case_gen_context_blocks( context_blocks.append(f"## 场景描述\n{description}") - kb_block = _build_kb_context_block(kb_context, knowledge_base_names, tenant_id) + kb_block = _build_kb_context_block( + kb_context, + resolved_kb_names or knowledge_base_names, + tenant_id, + ) if kb_block: context_blocks.append(kb_block) - if file_content and file_name: - context_blocks.append(f"## 上传文档: {file_name}\n{file_content[:3000]}") return context_blocks def _build_case_gen_user_prompt( - context_blocks, count, kb_context, agent_id, file_content + context_blocks, count, kb_context, agent_id ): """Append generation instructions from YAML template to assembled context.""" user_prompt = "\n\n".join(context_blocks) @@ -755,8 +921,6 @@ def _build_case_gen_user_prompt( sources.append("知识库检索内容") if agent_id: sources.append("Agent 配置(含工具、技能、子智能体)") - if file_content: - sources.append("上传的参考文档") source_list = "、".join(sources) template = get_prompt_template("evaluation_generate_cases_system", "zh") @@ -843,47 +1007,6 @@ def _call_llm_and_extract_cases(model_id, user_prompt, tenant_id) -> list: # ── Public API ─────────────────────────────────────────────────────── -def generate_cases_by_llm_impl( - description, - count, - tenant_id, - model_id, - knowledge_base_names=None, - agent_id=None, - agent_version_no=None, - file_content=None, - file_name=None, -): - logger.info("Generating %d cases, KBs=%s", count, knowledge_base_names) - - kb_context = _do_kb_search(knowledge_base_names, description, model_id, tenant_id) - context_blocks = _build_case_gen_context_blocks( - agent_id, - tenant_id, - description, - kb_context, - knowledge_base_names, - file_content, - file_name, - ) - user_prompt = _build_case_gen_user_prompt( - context_blocks, - count, - kb_context, - agent_id, - file_content, - ) - try: - cases = _call_llm_and_extract_cases(model_id, user_prompt, tenant_id) - except AppException: - raise - except Exception as exc: - raise AppException( - ErrorCode.COMMON_VALIDATION_ERROR, f"Case generation failed: {exc}" - ) from exc - return cases[:count] - - def _report_progress(set_id, tenant_id, progress): """Update generation progress on the evaluation set.""" _update_generation_status(set_id, tenant_id, "GENERATING", progress) @@ -967,8 +1090,6 @@ def _generate_cases_async( description, count, model_id, - file_content, - file_name, agent_id, is_new_set=False, knowledge_base_names=None, @@ -978,8 +1099,8 @@ def _generate_cases_async( try: _report_progress(set_id, tenant_id, 0) - kb_context = _do_kb_search( - knowledge_base_names, description, model_id, tenant_id + kb_context, resolved_kb_names = _do_kb_search( + knowledge_base_names, description, model_id, tenant_id, user_id ) _report_progress(set_id, tenant_id, 8) @@ -989,22 +1110,19 @@ def _generate_cases_async( description, kb_context, knowledge_base_names, - file_content, - file_name, + resolved_kb_names, ) user_prompt = _build_case_gen_user_prompt( context_blocks, count, kb_context, agent_id, - file_content, ) logger.info( - "Case gen prompt length=%d, has_agent=%s, has_kb=%s, has_file=%s, head=%s", + "Case gen prompt length=%d, has_agent=%s, has_kb=%s, head=%s", len(user_prompt), bool(agent_id), bool(kb_context), - bool(file_content), user_prompt[:200], ) _report_progress(set_id, tenant_id, 10) diff --git a/backend/services/file_management_service.py b/backend/services/file_management_service.py index 69df45fd16..8e5639a16b 100644 --- a/backend/services/file_management_service.py +++ b/backend/services/file_management_service.py @@ -20,12 +20,15 @@ ASSET_OWNER_TENANT_ID, DATA_PROCESS_SERVICE, FILE_PREVIEW_SIZE_LIMIT, + MAX_KNOWLEDGE_FILE_SIZE_BYTES, MAX_CONCURRENT_UPLOADS, MODEL_CONFIG_MAPPING, OFFICE_MIME_TYPES, UPLOAD_FOLDER, ) +from consts.error_code import ErrorCode from consts.exceptions import ( + AppException, FileTooLargeException, NotFoundException, OfficeConversionException, @@ -375,6 +378,73 @@ async def _get_complete_upload_batch_size(files: List[UploadFile]) -> int: return total_size +async def _get_upload_size(upload: UploadFile) -> int: + """Return one upload's size without changing its stream position.""" + declared_size = getattr(upload, "size", None) + if isinstance(declared_size, int) and not isinstance(declared_size, bool) and declared_size > 0: + return declared_size + + file_object = getattr(upload, "file", None) + if file_object is not None: + original_position = None + try: + original_position = file_object.tell() + file_object.seek(0, os.SEEK_END) + measured_size = file_object.tell() + if isinstance(measured_size, int) and measured_size >= 0: + return measured_size + except (AttributeError, OSError, TypeError, ValueError): + pass + finally: + if original_position is not None: + try: + file_object.seek(original_position) + except (AttributeError, OSError, TypeError, ValueError): + logger.warning("Failed to restore upload file position") + + await upload.seek(0) + try: + content = await upload.read() + return len(content) + finally: + await upload.seek(0) + + +def _is_knowledge_base_upload(folder: Optional[str], index_name: Optional[str]) -> bool: + """Identify uploads that belong to the local knowledge-base document flow.""" + # The browser path uses the explicit knowledge_base folder. Northbound + # uploads omit the folder but always provide an index_name. Other callers + # may use an index_name for filename conflict resolution in an arbitrary + # storage folder, which must not inherit the KB document-size policy. + return folder == "knowledge_base" or (folder is None and bool(index_name)) + + +async def _validate_knowledge_file_sizes( + files: List[UploadFile], +) -> None: + """Reject oversized KB documents before any file is written to storage.""" + for upload in files: + if not upload: + continue + file_size = await _get_upload_size(upload) + if file_size <= MAX_KNOWLEDGE_FILE_SIZE_BYTES: + continue + + filename = upload.filename or "unknown" + limit_mb = MAX_KNOWLEDGE_FILE_SIZE_BYTES // (1024 * 1024) + raise AppException( + ErrorCode.FILE_TOO_LARGE, + f"Knowledge base file '{filename}' exceeds the {limit_mb} MB limit", + details={ + "resource": "knowledge_file", + "limit_bytes": MAX_KNOWLEDGE_FILE_SIZE_BYTES, + "limit_mb": limit_mb, + "file_name": filename, + "actual_bytes": file_size, + }, + ) + + async def upload_files_impl( destination: str, file: List[UploadFile], @@ -405,6 +475,8 @@ async def upload_files_impl( quota_status = None lifecycle_records = [] if destination == "local": + if _is_knowledge_base_upload(folder, index_name): + await _validate_knowledge_file_sizes(file) async with upload_semaphore: for f in file: if not f: @@ -423,6 +495,8 @@ async def upload_files_impl( errors.append(f"Failed to save file: {f.filename}") elif destination == "minio": + if _is_knowledge_base_upload(folder, index_name): + await _validate_knowledge_file_sizes(file) actual_folder = resolve_minio_upload_folder( folder, user_id, uploader_tenant_id) @@ -556,9 +630,14 @@ async def upload_files_impl( existing = await ElasticSearchService.list_files(index_name, include_chunks=False, vdb_core=vdb_core) existing_files = existing.get( "files", []) if isinstance(existing, dict) else [] - # Prefer 'file' field; fall back to 'filename' if present + # list_files merges durable lifecycle rows, including this + # batch's own -- exclude them or every first upload renames + # itself to _1 (only merged rows carry a file_id). + own_file_ids = {r["file_id"] for r in lifecycle_records if r.get("file_id")} existing_names = set() for item in existing_files: + if item.get("file_id") in own_file_ids: + continue name = (item.get("file") or item.get( "filename") or "").strip() if name: diff --git a/backend/services/knowledge_scope_service.py b/backend/services/knowledge_scope_service.py index 193ffd69b4..c7a23e94d3 100644 --- a/backend/services/knowledge_scope_service.py +++ b/backend/services/knowledge_scope_service.py @@ -2,9 +2,12 @@ import json import re import unicodedata +from copy import deepcopy from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Optional +from nexent.core.agents.agent_model import AgentConfig + from agents.create_agent_info import _resolve_runtime_tool_records from consts.exceptions import ValidationError from consts.model import ( @@ -169,6 +172,15 @@ def _walk_agent_tree( return nodes +def snapshot_runtime_knowledge_tree( + agent_id: int, + tenant_id: str, + version_no: int, +) -> List[Dict[str, Any]]: + """Freeze the knowledge-capable static tree for one request.""" + return deepcopy(_walk_agent_tree(agent_id, tenant_id, version_no)) + + def get_agent_knowledge_capabilities( agent_id: int, tenant_id: str, @@ -234,8 +246,16 @@ def get_agent_knowledge_capabilities( for node in agent_tree if node.get("has_static_scope_reference") ] + from services.runtime_knowledge_mount import RESERVED + + def root_parameters(class_name): + return {param["name"]: deepcopy(param.get("default")) + for tool in agent_tree[0]["tools"] if tool.get("class_name") == class_name + for param in tool.get("params") or [] if param["name"] not in RESERVED} if agent_tree else {} + sources = { "local": { + "default_retrieval_config": root_parameters(LOCAL_TOOL_CLASS), "enabled": local_enabled, "max_select": LOCAL_MAX_SELECT, "requires_same_embedding_model": True, @@ -244,6 +264,7 @@ def get_agent_knowledge_capabilities( "default_range_values": local_default_indices, }, "aidp": { + "default_retrieval_config": root_parameters(AIDP_TOOL_CLASS), "enabled": aidp_enabled, "max_select": AIDP_MAX_SELECT, "default_summary": "Follow each agent's default configuration", @@ -364,10 +385,14 @@ def resolve_knowledge_scope( version_no: Optional[int], is_debug: bool, request_tool_params: Optional[ToolParamsRequest] = None, + runtime_agent_tree: Optional[List[Dict[str, Any]]] = None, ) -> ResolvedKnowledgeScope: """Resolve one desired scope into per-agent tool overrides for this run.""" - resolved_version = resolve_root_version(agent_id, tenant_id, version_no, is_debug) - agent_tree = _walk_agent_tree(agent_id, tenant_id, resolved_version) + if runtime_agent_tree is None: + resolved_version = resolve_root_version(agent_id, tenant_id, version_no, is_debug) + agent_tree = _walk_agent_tree(agent_id, tenant_id, resolved_version) + else: + agent_tree = deepcopy(runtime_agent_tree) desired = scope.model_dump(mode="json") warnings: List[Dict[str, Any]] = [] @@ -409,7 +434,7 @@ def resolve_knowledge_scope( aidp_capable = False for node in agent_tree: - agent_name = node.get("agent_name") + agent_name = node.get("runtime_ref") or node.get("agent_name") if not agent_name: continue for tool in node["tools"]: @@ -522,6 +547,84 @@ def resolve_knowledge_scope( ) +def resolve_executable_knowledge_scope( + root: AgentConfig, + scope: ConversationKnowledgeScopeRequest, + *, + tenant_id: str, + user_id: str, +) -> tuple[AgentConfig, ResolvedKnowledgeScope]: + """Apply an authorized scope to a copied executable tree using runtime identities. + + System roots need no persisted Agent ID. Resource authorization remains in + the existing scope resolver; this adapter never queries Agent repositories. + """ + from utils.runtime_config_utils import clone_runtime_config + + compiled = clone_runtime_config(root) + nodes: Dict[str, AgentConfig] = {} + projection: List[Dict[str, Any]] = [] + + def visit(agent: AgentConfig) -> None: + runtime_ref = agent.runtime_ref + if not runtime_ref or runtime_ref in nodes: + raise ValidationError("Executable Agent runtime references must be present and unique") + nodes[runtime_ref] = agent + projection.append({ + "runtime_ref": runtime_ref, + "tools": [ + { + "class_name": tool.class_name, + "name": tool.name, + "params": [ + {"name": name, "default": clone_runtime_config(value)} + for name, value in (tool.params or {}).items() + if name in (LOCAL_RANGE_PARAM, AIDP_RANGE_PARAM) + ], + } + for tool in agent.tools + if tool.class_name in (LOCAL_TOOL_CLASS, AIDP_TOOL_CLASS) + ], + }) + for child in agent.managed_agents: + visit(child) + + visit(compiled) + resolved = resolve_knowledge_scope( + scope, agent_id=0, tenant_id=tenant_id, user_id=user_id, + version_no=None, is_debug=False, runtime_agent_tree=projection, + ) + overrides = resolved.tool_params.model_dump(mode="python")["agents"] + for runtime_ref, agent in nodes.items(): + tool_overrides = overrides.get(runtime_ref, {}).get("tools", {}) + for tool in agent.tools: + params = tool_overrides.get(tool.name or tool.class_name) + if params is None: + continue + tool.params = {**(tool.params or {}), **deepcopy(params)} + metadata = dict(tool.metadata or {}) + if tool.class_name == LOCAL_TOOL_CLASS: + allowed = list(params[LOCAL_RANGE_PARAM]) + metadata["allowed_index_names"] = allowed + metadata["display_name_to_index_map"] = { + name: index for name, index in metadata.get("display_name_to_index_map", {}).items() + if index in allowed + } + metadata["index_name_to_display_map"] = { + index: name for index, name in metadata.get("index_name_to_display_map", {}).items() + if index in allowed + } + elif tool.class_name == AIDP_TOOL_CLASS: + allowed = list(params[AIDP_RANGE_PARAM]) + metadata["allowed_kds_set"] = allowed + metadata["kds_name_to_id_map"] = { + name: value for name, value in metadata.get("kds_name_to_id_map", {}).items() + if value in allowed + } + tool.metadata = metadata + return compiled, resolved + + def build_runtime_knowledge_policy(language: str) -> str: """Build the trusted platform rule that prevents scope expansion.""" if language == "zh": diff --git a/backend/services/memory_dreaming_summarizer.py b/backend/services/memory_dreaming_summarizer.py index 859d8eb50a..c499da452a 100644 --- a/backend/services/memory_dreaming_summarizer.py +++ b/backend/services/memory_dreaming_summarizer.py @@ -22,6 +22,7 @@ ) from utils.config_utils import get_model_name_from_config, tenant_config_manager from services.thread_lifecycle_service import config_thread_manager +from utils.monitoring_identity import resolve_monitoring_user_email logger = logging.getLogger(__name__) @@ -74,6 +75,7 @@ def __init__(self, tenant_id: str, user_id: str): def __call__(self, request: DreamingSummarizationRequest) -> DreamingSummarizationOutput: metadata = AgentRunMetadata( tenant_id=self.tenant_id, user_id=self.user_id, + user_email=resolve_monitoring_user_email(self.user_id, self.tenant_id), agent_id=int(request.agent_id) if request.agent_id and request.agent_id.isdigit() else None, extra_metadata={"dreaming_run_id": request.run_id, "dreaming_attempt": request.attempt}, ) diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index 15ce6fb5ec..d93d3bc4f5 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -1,6 +1,8 @@ import logging from typing import List, Optional +import httpx + from nexent.core import MessageObserver from nexent.monitor import set_monitoring_context, set_monitoring_operation @@ -148,6 +150,38 @@ async def _provider_catalog_connectivity_check( return any(str(model.get("id", "")).lower() == expected_model_id for model in model_list) +async def _image_generation_connectivity_check( + model_name: str, + base_url: str, + api_key: str, + ssl_verify: bool = True, + timeout_seconds: Optional[float] = None, +) -> bool: + """Probe an image-generation (vlm2) model via /images/generations. + + Image-generation models are not served on the chat-completions endpoint, + so the shared VLM chat probe reports them as "model does not exist". A + minimal real generation is the only reliable probe; each call produces + one billable image, so the payload is kept as small as possible. + """ + url = (base_url or "").rstrip("/") + if not url.endswith("/images/generations"): + url = f"{url}/images/generations" + # Image generation is slower than chat; default well above the 5s used + # by the chat-style probes. + timeout = timeout_seconds if timeout_seconds else 60.0 + try: + async with httpx.AsyncClient(verify=ssl_verify, timeout=timeout) as client: + response = await client.post( + url, + json={"model": model_name, "prompt": "connectivity check"}, + headers={"Authorization": f"Bearer {api_key}"}, + ) + return response.status_code == 200 + except Exception: + return False + + async def _perform_connectivity_check( model_name: str, model_type: str, @@ -250,7 +284,26 @@ async def _perform_connectivity_check( rerank_config, "rerank", "rerank", None, model_name=model_name, ).health_check() - elif model_type in ("vlm", "vlm2", "vlm3", "vlm4"): + elif model_type == "vlm2": + # Image-generation models are served on /images/generations, not on + # chat/completions — the shared VLM chat probe reports them as + # "model does not exist". Try the free provider-catalog check first, + # then probe the generation endpoint directly. + if await _provider_catalog_connectivity_check( + model_name=model_name, + model_type=model_type, + model_api_key=model_api_key, + model_factory=model_factory, + ): + return True + connectivity = await _image_generation_connectivity_check( + model_name=model_name, + base_url=model_base_url, + api_key=model_api_key, + ssl_verify=ssl_verify, + timeout_seconds=timeout_seconds, + ) + elif model_type in ("vlm", "vlm3", "vlm4"): if await _provider_catalog_connectivity_check( model_name=model_name, model_type=model_type, diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index ff0b871a08..0e384d2ded 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -53,9 +53,13 @@ split_repo_name, sort_models_by_id, ) +from utils.reasoning import normalize_reasoning_params # Model Catalog - 预置模型目录,自动填充默认配置 try: - from configs.model_catalog_loader import apply_catalog_defaults + from configs.model_catalog_loader import ( + apply_catalog_defaults, + resolve_reasoning_capability, + ) except Exception as _exc: # noqa: BLE001 logger_catalog_import = logging.getLogger("model_catalog") logger_catalog_import.warning("model_catalog_loader import failed: %s. Catalog auto-fill disabled.", _exc) @@ -63,10 +67,107 @@ def apply_catalog_defaults(_model_data: Dict[str, Any], _provider_hint: Optional[str]) -> bool: # type: ignore[no-redef] return False + def resolve_reasoning_capability( # type: ignore[no-redef] + model_name: str, + base_url: Optional[str] = None, + provider_hint: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + return None + logger = logging.getLogger("model_management_service") INDEPENDENT_MULTIMODAL_MODEL_TYPES = {"vlm", "vlm2", "vlm3", "vlm4"} CAPACITY_COVERAGE_MODEL_TYPES = {"llm", "vlm", "vlm2", "vlm3", "vlm4"} +COMMON_REASONING_LEVELS = ("low", "medium", "high") +COMMON_REASONING_DEFAULT = "auto" + + +def _enrich_model_reasoning_capability(model: Dict[str, Any]) -> None: + """Attach catalog-declared reasoning capability to an API model row.""" + if model.get("model_type") not in {"llm", "chat"}: + return + model_name = add_repo_to_name( + model.get("model_repo", ""), model.get("model_name", "") + ) + capability = resolve_reasoning_capability( + model_name=model_name, + base_url=model.get("base_url"), + provider_hint=model.get("model_factory"), + ) + model["extra_params"] = normalize_reasoning_params( + model.get("extra_params"), capability + ) or None + if capability is not None: + model["reasoning_capability"] = capability + + +def get_model_reasoning_capability( + model_name: str, + base_url: Optional[str] = None, + provider_hint: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Resolve reasoning controls using the model ID and provider API URL.""" + return resolve_reasoning_capability(model_name, base_url, provider_hint) + + +def _enrich_discovered_model_reasoning_capability( + model: Dict[str, Any], + base_url: Optional[str], + provider_hint: Optional[str], +) -> None: + """Attach build-time models.dev capability to a provider discovery row.""" + # OpenAI-compatible discovery rows may omit model_type when the request + # already filters to LLMs; treat that legacy shape as an LLM row. + if model.get("model_type") not in {None, "llm", "chat"}: + return + model_name = str(model.get("id") or model.get("model_name") or "").strip() + if not model_name: + return + capability = resolve_reasoning_capability(model_name, base_url, provider_hint) + if capability is not None: + model["reasoning_capability"] = capability + + +def _apply_model_reasoning_default( + model_data: Dict[str, Any], provider_hint: Optional[str] +) -> None: + """Persist an enabled model's reasoning default without enabling it implicitly. + + The value is kept in the existing ``extra_params`` JSONB column, so this + also upgrades old/custom model IDs without requiring a schema migration. + New models keep the switch disabled unless the caller explicitly enables + it. + """ + if model_data.get("model_type") not in {"llm", "chat"}: + return + extra_params = dict(model_data.get("extra_params") or {}) + enabled = extra_params.get("enable_thinking") + if enabled is not True: + if enabled is False: + extra_params.pop("reasoning_effort", None) + extra_params.pop("reasoning_budget_tokens", None) + model_data["extra_params"] = extra_params or None + return + model_name = add_repo_to_name( + model_data.get("model_repo", ""), model_data.get("model_name", "") + ) + capability = resolve_reasoning_capability( + model_name=model_name, + base_url=model_data.get("base_url"), + provider_hint=provider_hint or model_data.get("model_factory"), + ) + if isinstance(capability, dict) and capability.get("status") == "supported": + levels = capability.get("levels") or list(COMMON_REASONING_LEVELS) + else: + # Keep a provider-agnostic profile for unknown/custom model IDs. The + # provider remains the source of truth if it rejects a concrete value. + levels = list(COMMON_REASONING_LEVELS) + if extra_params.get("reasoning_effort") == "auto": + return + if extra_params.get("reasoning_effort") in levels: + return + extra_params["reasoning_effort"] = COMMON_REASONING_DEFAULT + model_data["extra_params"] = extra_params # OpenTelemetry counter for silent catalog-matcher failures during the @@ -309,7 +410,12 @@ async def resolve_embedding_base_url(model_data: Dict[str, Any]) -> Tuple[Option return None, None -async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict[str, Any]): +async def create_model_for_tenant( + user_id: str, + tenant_id: str, + model_data: Dict[str, Any], + skip_default_backfill: bool = False, +): """Create a single model record for the given tenant. Raises ValueError on display name conflict or invalid input. @@ -372,6 +478,8 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict _coerce_legacy_max_tokens_alias(model_data) + _apply_model_reasoning_default(model_data, _provider_hint) + # Use NOT_DETECTED status as default model_data["connect_status"] = model_data.get( "connect_status") or ModelConnectStatusEnum.NOT_DETECTED.value @@ -425,8 +533,20 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict f"Model {model_data['display_name']} created successfully") # Auto-configure default-model slots that the tenant never set. - auto_configured = _backfill_default_model_slots(user_id, tenant_id) + # Only the models created by THIS call are eligible for empty slots. + # Batch imports pass skip_default_backfill on their per-row creates + # and finalize once after the whole batch (backfill_defaults), so the + # first row no longer permanently claims empty slots. + if skip_default_backfill: + return {"auto_configured_defaults": []} + created_ids = _ids_for_created_models( + [model_data["display_name"]], tenant_id, model_data.get("model_type")) + auto_configured = _backfill_default_model_slots( + user_id, tenant_id, new_model_ids=created_ids) return {"auto_configured_defaults": auto_configured} + except ValueError: + # Let the API layer map conflicts to 409 instead of 500. + raise except Exception as e: logging.error(f"Failed to create model: {str(e)}") raise Exception(f"Failed to create model: {str(e)}") @@ -475,6 +595,16 @@ async def create_provider_models_for_tenant(tenant_id: str, provider_request: Di model_list = merge_existing_model_attributes( model_list, tenant_id, provider_request["provider"], model_type) + # The provider /models response only identifies model IDs. Resolve + # reasoning controls from the build-time models.dev snapshot using the + # exact API URL supplied for this discovery request. + for model in model_list: + _enrich_discovered_model_reasoning_capability( + model, + provider_request.get("base_url"), + provider_request.get("provider"), + ) + # Sort model list by ID model_list = sort_models_by_id(model_list) @@ -521,11 +651,12 @@ def _resolve_existing_slot_config(tenant_id: str, config_key: str): """Classify a default-model slot's existing config row. Returns (live_model_id, stale_row): - - live_model_id set: the configured default still exists -- backfill must - skip (user's explicit choice). + - live_model_id set: the slot is occupied by a live model (user- or + system-configured) -- backfill must never touch it. - stale_row set: a row exists but its model has been deleted (dangling default) -- backfill repairs that row in place. - - both None: the slot was never configured -- backfill inserts a row. + - both None: the slot is empty (never configured or cleared by the + user) -- backfill fills it from the current call's new models. """ row = get_single_config_info(tenant_id, config_key) # Note: the DB helper returns {} (not None) when no row matches. @@ -541,14 +672,58 @@ def _resolve_existing_slot_config(tenant_id: str, config_key: str): return None, row -def _backfill_default_model_slots(user_id: str, tenant_id: str) -> List[Dict[str, Any]]: +def _ids_for_created_models( + display_names: List[str], + tenant_id: str, + model_type: Optional[str] = None, +) -> set: + """Resolve the ids of freshly created models from their display names. + + create_model_record returns only a bool, so the ids are recovered by + display-name lookup. An optional model_type restricts the match; for + multi_embedding creates the embedding twin is included automatically + (both records share the display name). + """ + accepted_types = None + if model_type: + accepted_types = {model_type} + if model_type == "multi_embedding": + accepted_types.add("embedding") + ids = set() + for name in display_names: + if not name: + continue + for record in get_models_by_display_name(name, tenant_id): + if accepted_types is None or record.get("model_type") in accepted_types: + ids.add(record["model_id"]) + return ids + + +def _backfill_default_model_slots( + user_id: str, + tenant_id: str, + new_model_ids: Optional[set] = None, +) -> List[Dict[str, Any]]: """Auto-configure default-model slots after models are created. - A slot is skipped only when its config row points at a still-existing - model; empty slots and dangling rows (model deleted) are (re)filled. The - candidate pool is the tenant's live models of the matching type, ranked by - availability then context size. Failures are logged and skipped so - backfill can never break the create flow. + Slot handling: + - An OCCUPIED slot (any live model, whether the user picked it or an + earlier backfill did) is never touched: adding more models later must + not move an existing default. Batch imports therefore mark their + per-row creates with skip_default_backfill and finalize once after the + whole batch, so the first row no longer permanently claims the slot. + - An EMPTY slot (never configured, or deliberately cleared by the user) + is filled ONLY from the models created in the current call + (new_model_ids). Resurrecting an older model the user passed over + (e.g. after clearing a default) would silently override that choice. + Legacy callers that omit new_model_ids keep the old all-candidates + behaviour. + - Dangling rows (model deleted) are repaired from the full candidate + pool: the previous choice is gone, so the best remaining replacement + is appropriate. + + Failures are logged and skipped so backfill can never break the create + flow. Returns a list of {"config_key", "model_id", "display_name", "model_type"} entries describing what was auto-configured (empty when nothing changed). @@ -557,28 +732,36 @@ def _backfill_default_model_slots(user_id: str, tenant_id: str) -> List[Dict[str try: for slot_name, model_type in _AUTO_CONFIGURABLE_MODEL_SLOTS.items(): config_key = MODEL_CONFIG_MAPPING[slot_name] - live_model_id, stale_row = _resolve_existing_slot_config( + live_model_id, row = _resolve_existing_slot_config( tenant_id, config_key) if live_model_id is not None: - # A live, user-configured default: never touch it. + # Occupied by a live model (user- or system-configured): + # never touch it. continue candidates = get_model_records({"model_type": model_type}, tenant_id) + + if row is None and new_model_ids is not None: + # Empty slot: only consider what this create call added. + candidates = [ + m for m in candidates if m["model_id"] in new_model_ids + ] if not candidates: continue - selected = sorted(candidates, key=_default_model_candidate_sort_key)[0] - if stale_row is not None: + if row is not None: # Dangling row (model deleted): repair it in place instead of # appending another row to the key's history. + repair = sorted(candidates, key=_default_model_candidate_sort_key)[0] success = update_config_by_tenant_config_id( - stale_row["tenant_config_id"], str(selected["model_id"]) + row["tenant_config_id"], str(repair["model_id"]) ) else: + insert_pick = sorted(candidates, key=_default_model_candidate_sort_key)[0] success = insert_config({ "tenant_id": tenant_id, "config_key": config_key, - "config_value": str(selected["model_id"]), + "config_value": str(insert_pick["model_id"]), "created_by": user_id, "updated_by": user_id, }) @@ -588,13 +771,14 @@ def _backfill_default_model_slots(user_id: str, tenant_id: str) -> List[Dict[str "False for key=%s tenant=%s", config_key, tenant_id) continue + picked = repair if row is not None else insert_pick logging.info( "Auto-configured default %s model to '%s' (model_id=%s) for tenant %s", - model_type, selected.get("display_name"), selected["model_id"], tenant_id) + model_type, picked.get("display_name"), picked["model_id"], tenant_id) auto_configured.append({ "config_key": config_key, - "model_id": selected["model_id"], - "display_name": selected.get("display_name"), + "model_id": picked["model_id"], + "display_name": picked.get("display_name"), "model_type": model_type, }) except Exception as exc: @@ -610,6 +794,7 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay model_type = batch_payload["type"] model_list: List[Dict[str, Any]] = batch_payload.get("models", []) model_api_key: str = batch_payload.get("api_key", "") + created_display_names: List[str] = [] if provider == ProviderEnum.SILICON.value: model_url = SILICON_BASE_URL @@ -714,12 +899,22 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay # the batch_create call). # ============================================================ apply_catalog_defaults(model_dict, provider) + _apply_model_reasoning_default(model_dict, provider) create_model_record(model_dict, user_id, tenant_id) + if model_dict.get("display_name"): + created_display_names.append(model_dict["display_name"]) logging.debug(f"Model {model['id']} created successfully") # Auto-configure default-model slots that the tenant never set. - auto_configured = _backfill_default_model_slots(user_id, tenant_id) + # Only the models created by THIS call are eligible for empty slots. + created_ids = _ids_for_created_models( + created_display_names, tenant_id) + auto_configured = _backfill_default_model_slots( + user_id, tenant_id, new_model_ids=created_ids) return {"auto_configured_defaults": auto_configured} + except ValueError: + # Let the API layer map invalid entries to 422 instead of 500. + raise except Exception as e: logging.error(f"Failed to batch create models: {str(e)}") raise Exception(f"Failed to batch create models: {str(e)}") @@ -955,6 +1150,7 @@ async def list_models_for_tenant(tenant_id: str): } for record in records: + _enrich_model_reasoning_capability(record) record["model_name"] = add_repo_to_name( model_repo=record["model_repo"], model_name=record["model_name"], @@ -981,17 +1177,25 @@ async def list_llm_models_for_tenant(tenant_id: str): records = get_model_records({"model_type": "llm"}, tenant_id) result: List[Dict[str, Any]] = [] for record in records: + _enrich_model_reasoning_capability(record) result.append({ "model_id": record["model_id"], "model_name": add_repo_to_name( model_repo=record["model_repo"], model_name=record["model_name"], ), + "model_type": record.get("model_type", "llm"), "connect_status": ModelConnectStatusEnum.get_value(record.get("connect_status")), "display_name": record["display_name"], "api_key": record.get("api_key", ""), "base_url": record.get("base_url", ""), - "max_tokens": record.get("max_tokens", 4096) + "max_tokens": record.get("max_tokens", 4096), + "extra_params": record.get("extra_params"), + **( + {"reasoning_capability": record["reasoning_capability"]} + if record.get("reasoning_capability") is not None + else {} + ), }) logging.debug("Successfully retrieved model list") diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 11e9424a7a..49bdf6edaf 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -74,6 +74,8 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a A dictionary ready to be passed to *create_model_record*. """ # Split repo/name once so it can be reused multiple times. + if not model.get("id"): + raise ValueError("batch model entry is missing required field 'id'") model_repo, model_name = split_repo_name(model["id"]) model_display_name = add_repo_to_name(model_repo, model_name) diff --git a/backend/services/nl2agent_service.py b/backend/services/nl2agent_service.py index 85e8f3b31c..6e5ce73ee9 100644 --- a/backend/services/nl2agent_service.py +++ b/backend/services/nl2agent_service.py @@ -29,10 +29,13 @@ ) from agents.nl2agent_agent import create_nl2agent_agent_config from consts.const import ( + TOKEN, ENABLE_AIDP_KNOWLEDGE, LOCAL_MCP_SERVER, + MCP_REQUEST_TIMEOUT_SECONDS, MODEL_CONFIG_MAPPING, ) +from utils.mcp_url_utils import get_tenant_local_mcp_server from consts.model import HistoryItem, NL2AgentRunRequest, ToolSourceEnum from database.agent_db import ( query_all_agent_info_by_tenant_id, @@ -100,6 +103,7 @@ def _is_nl2agent_recommendable_tool(name: str) -> bool: "greeting_message", "example_questions", ) +_WORKBENCH_DRAFT_NAME = re.compile(r"Workbench Draft [a-z0-9]+-[a-z0-9]{6}\Z") class _Nl2AgentBoundaryObserver(MessageObserver): @@ -179,6 +183,13 @@ def _update_agent_draft_from_fields( patch = fields.model_dump(mode="python", exclude_unset=True) generated_name = patch.get("name") + if "display_name" in patch and not ( + generated_name is not None + and not str(draft.get("name") or "").strip() + and not str(draft.get("description") or "").strip() + and _WORKBENCH_DRAFT_NAME.fullmatch(str(draft.get("display_name") or "")) + ): + raise Nl2AgentDraftSaveError("agent_display_name_immutable") if generated_name is not None: if str(draft.get("name") or "").strip(): raise Nl2AgentDraftSaveError("agent_name_already_set") @@ -1383,7 +1394,7 @@ async def build_nl2agent_run_info( agent_config.capacity_snapshot = capacity_snapshot agent_config.context_budget_snapshot = context_budget_snapshot mcp_config: dict[str, Any] = { - "url": urljoin(LOCAL_MCP_SERVER, "sse"), + "url": get_tenant_local_mcp_server(tenant_id), "transport": "sse", "httpx_client_factory": create_httpx_client, "bypass_proxy": True, @@ -1392,6 +1403,8 @@ async def build_nl2agent_run_info( if authorization: mcp_headers["Authorization"] = authorization mcp_headers[NL2AGENT_AGENT_ID_HEADER] = str(request.agent_id) + mcp_headers["X-Tenant-ID"] = str(tenant_id) + mcp_headers["X-Nexent-Internal-Token"] = TOKEN if mcp_headers: mcp_config["headers"] = mcp_headers @@ -1405,6 +1418,7 @@ async def build_nl2agent_run_info( ), agent_config=agent_config, mcp_host=[mcp_config], + mcp_request_timeout_seconds=MCP_REQUEST_TIMEOUT_SECONDS, history=_convert_history(request.history), stop_event=stop_event, capacity_snapshot=capacity_snapshot, diff --git a/backend/services/nl2skill_service.py b/backend/services/nl2skill_service.py index 47f63ae43f..dc159e1eef 100644 --- a/backend/services/nl2skill_service.py +++ b/backend/services/nl2skill_service.py @@ -12,7 +12,10 @@ from nexent.core.agents.run_agent import agent_run from nexent.core.utils.observer import MessageObserver -from agents.create_agent_info import create_model_config_list +from agents.create_agent_info import ( + create_model_config_list, + join_minio_file_description_to_query, +) from agents.nl2skill_agent import create_nl2skill_agent_config from consts.const import LANGUAGE, MODEL_CONFIG_MAPPING from consts.model import HistoryItem, NL2SkillRunRequest @@ -183,11 +186,16 @@ async def build_nl2skill_run_info( template_language = LANGUAGE["EN"] if language == LANGUAGE["EN"] else LANGUAGE["ZH"] target_files = _extract_target_files(request.query, request.draft_snapshot) draft_snapshot = _normalize_draft_snapshot(request.draft_snapshot) + final_query = await join_minio_file_description_to_query( + minio_files=request.minio_files, + query=request.query, + history=request.history, + ) template = get_skill_creation_simple_prompt_template( language=template_language, existing_skill=draft_snapshot, complexity=request.complexity, - user_request=request.query, + user_request=final_query, target_files=target_files, ) model_config_list = await create_model_config_list(tenant_id) @@ -202,7 +210,7 @@ async def build_nl2skill_run_info( ) return AgentRunInfo( - query=template.get("user_prompt") or request.query, + query=template.get("user_prompt") or final_query, model_config_list=model_config_list, observer=MessageObserver(lang=template_language), agent_config=create_nl2skill_agent_config( diff --git a/backend/services/oauth_service.py b/backend/services/oauth_service.py index d632b7edc1..80fb734da7 100644 --- a/backend/services/oauth_service.py +++ b/backend/services/oauth_service.py @@ -23,7 +23,7 @@ SUPABASE_JWT_SECRET, JWT_EXPIRY_SECONDS, ) -from consts.exceptions import OAuthLinkError, OAuthProviderError +from consts.exceptions import OAuthLinkError, OAuthProviderError, TenantResourceLimitError from services.asset_owner_visibility import require_asset_owner_enabled from consts.oauth_providers import ( get_all_provider_definitions, @@ -475,12 +475,19 @@ async def complete_pending_oauth_account( user_role = _role_from_invitation_type(invitation_info.get("code_type", "USER_INVITE")) is_asset_owner_registration = user_role == ASSET_OWNER_ROLE - insert_user_tenant( - user_id=supabase_user_id, - tenant_id=tenant_id, - user_role=user_role, - user_email=final_email, - ) + try: + insert_user_tenant( + user_id=supabase_user_id, + tenant_id=tenant_id, + user_role=user_role, + user_email=final_email, + ) + except TenantResourceLimitError: + # The Supabase identity was created before the local tenant-limit check. + from utils.auth_utils import delete_supabase_user + + delete_supabase_user(supabase_user_id) + raise invitation_result = use_invitation_code(normalized_invite_code, supabase_user_id) group_ids = invitation_result.get("group_ids", []) diff --git a/backend/services/official_agent_bundle_service.py b/backend/services/official_agent_bundle_service.py new file mode 100644 index 0000000000..37dba4fe68 --- /dev/null +++ b/backend/services/official_agent_bundle_service.py @@ -0,0 +1,263 @@ +"""Load and validate official agent bundles from external profile directories.""" + +from __future__ import annotations + +import json +import logging +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +from pydantic import BaseModel + +from consts.model import AgentRepositorySnapshot, KnowledgeBaseSeed + +logger = logging.getLogger("official_agent_bundle_service") + +MAX_BUNDLE_BYTES = 100 * 1024 * 1024 +MAX_BUNDLE_FILES = 1_000 +MAX_BUNDLE_FILE_BYTES = 20 * 1024 * 1024 + + +class _BundleSkillZipEntry(BaseModel): + skill_name: str + skill_zip_base64: str + + +@dataclass(frozen=True) +class OfficialAgentBundle: + profile: str + name: str + snapshot: AgentRepositorySnapshot + display_name: str | None = None + description: str | None = None + tags: list[str] | None = None + icon: str | None = None + bundle_path: Path | None = None + knowledge_bases: list[KnowledgeBaseSeed] | None = None + knowledge_base_documents: dict[str, list[dict[str, str]]] | None = None + + +def _seed_doc(file_name: str, *, content: str | None = None, file_path: str | None = None) -> dict[str, str]: + result = {"file_name": file_name} + if content is not None: + result["content"] = content + if file_path is not None: + result["file_path"] = file_path + return result + + +def parse_official_agent_profiles(value: str | Iterable[str] | None) -> list[str]: + """Normalize a comma-separated profile setting without accepting paths.""" + values = value.split(",") if isinstance(value, str) else (value or []) + profiles: list[str] = [] + for raw in values: + profile = str(raw).strip() + if not profile or profile in {".", ".."} or "/" in profile or "\\" in profile: + raise ValueError(f"invalid official agent profile: {profile!r}") + if profile not in profiles: + profiles.append(profile) + return profiles + + +def _safe_member_path(root: Path, member_name: str) -> Path | None: + if not member_name or "\x00" in member_name: + return None + candidate = (root / member_name.replace("\\", "/")).resolve() + try: + candidate.relative_to(root.resolve()) + except ValueError: + return None + return candidate + + +def _read_bundle_json(extract_dir: Path) -> dict[str, Any]: + path = extract_dir / "agent.json" + if not path.is_file(): + raise ValueError("official agent bundle is missing agent.json") + with path.open(encoding="utf-8") as stream: + data = json.load(stream) + if not isinstance(data, dict): + raise ValueError("agent.json must contain an object") + return data + + +def _snapshot_from_json(data: dict[str, Any]) -> AgentRepositorySnapshot: + payload = data.get("snapshot") if isinstance(data.get("snapshot"), dict) else data + AgentRepositorySnapshot.model_rebuild( + force=True, + _types_namespace={"SkillZipEntry": _BundleSkillZipEntry}, + ) + return AgentRepositorySnapshot.model_validate(payload) + + +def _load_skill_entries(extract_dir: Path, snapshot: AgentRepositorySnapshot) -> list[_BundleSkillZipEntry]: + declared = { + skill_name + for agent in snapshot.agent_info.values() + for skill_name in (agent.skill_names or []) + if skill_name + } + entries: list[_BundleSkillZipEntry] = [] + for skill_name in sorted(declared): + if Path(skill_name).name != skill_name: + raise ValueError(f"unsafe official skill name: {skill_name}") + path = extract_dir / "skills" / f"{skill_name}.zip" + if path.is_file(): + entries.append(_BundleSkillZipEntry(skill_name=skill_name, skill_zip_base64=__import__("base64").b64encode(path.read_bytes()).decode("ascii"))) + return entries + + +def _load_kb_documents(extract_dir: Path, data: dict[str, Any]) -> dict[str, list[dict[str, str]]]: + result: dict[str, list[dict[str, str]]] = {} + raw_knowledge_bases = data.get("knowledge_bases", []) or [] + if isinstance(raw_knowledge_bases, dict): + raw_knowledge_bases = [raw_knowledge_bases] + for raw_kb in raw_knowledge_bases: + if not isinstance(raw_kb, dict): + continue + logical_name = raw_kb.get("logical_index_name") + if not isinstance(logical_name, str) or Path(logical_name).name != logical_name: + raise ValueError("unsafe official knowledge base name") + docs: list[dict[str, str]] = [] + kb_dir = extract_dir / "kb" / logical_name + if kb_dir.is_dir(): + for path in sorted(kb_dir.iterdir()): + if not path.is_file() or path.name.startswith("."): + continue + if path.suffix.lower() in {".md", ".txt", ".markdown"}: + docs.append(_seed_doc(path.name, content=path.read_text(encoding="utf-8"))) + else: + docs.append(_seed_doc(path.name, file_path=str(path))) + if docs: + result[logical_name] = docs + return result + + +def _load_kb_metadata(data: dict[str, Any]) -> list[KnowledgeBaseSeed]: + """Load bundle KB declarations, preserving their user-facing names.""" + raw_knowledge_bases = data.get("knowledge_bases", []) or [] + if isinstance(raw_knowledge_bases, dict): + raw_knowledge_bases = [raw_knowledge_bases] + if not isinstance(raw_knowledge_bases, list): + raise ValueError("official knowledge_bases must be an object or list") + return [KnowledgeBaseSeed.model_validate(item) for item in raw_knowledge_bases] + + +def _load_zip_bundle(profile: str, path: Path) -> OfficialAgentBundle | None: + if path.stat().st_size > MAX_BUNDLE_BYTES: + raise ValueError("official agent bundle exceeds size limit") + with tempfile.TemporaryDirectory(prefix="nexent-official-agent-") as temp_dir: + extract_dir = Path(temp_dir) + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + if len(members) > MAX_BUNDLE_FILES: + raise ValueError("official agent bundle contains too many files") + for member in members: + target = _safe_member_path(extract_dir, member.filename) + if target is None: + raise ValueError("official agent bundle contains unsafe path") + if member.file_size > MAX_BUNDLE_FILE_BYTES: + raise ValueError("official agent bundle contains oversized file") + archive.extractall(extract_dir) + data = _read_bundle_json(extract_dir) + snapshot = _snapshot_from_json(data) + knowledge_bases = _load_kb_metadata(data) + skills = _load_skill_entries(extract_dir, snapshot) + if skills: + snapshot = snapshot.model_copy(update={"skills": skills}) + kb_docs = _load_kb_documents(extract_dir, data) + return OfficialAgentBundle( + profile=profile, + name=path.stem, + snapshot=snapshot, + display_name=data.get("display_name"), + description=data.get("description"), + tags=data.get("tags"), + icon=data.get("icon"), + bundle_path=path, + knowledge_bases=knowledge_bases, + knowledge_base_documents=kb_docs, + ) + + +def load_official_bundles(base_dir: str | Path, profiles: Iterable[str]) -> list[OfficialAgentBundle]: + """Load selected profiles, isolating invalid bundles and rejecting key collisions.""" + root = Path(base_dir).resolve() + bundles: list[OfficialAgentBundle] = [] + seen_names: set[str] = set() + selected_profiles = parse_official_agent_profiles(profiles) + selected_profile_set = set(selected_profiles) + available_profiles = { + path.name + for path in root.iterdir() + if path.is_dir() + } if root.is_dir() else set() + for profile in selected_profiles: + if profile not in available_profiles: + logger.warning("Official agent profile directory not found: %s", profile) + + # Recursively scan only the configured root. User-selected profile names + # are used for filtering scan results, never for path construction or + # recursive traversal. + bundle_paths = [ + path + for path in root.rglob("*.zip") + if path.parent.name != "skills" + and path.relative_to(root).parts[0] in selected_profile_set + ] if root.is_dir() else [] + bundle_dirs = [ + path.parent + for path in root.rglob("agent.json") + if path.is_file() + and path.relative_to(root).parts[0] in selected_profile_set + ] if root.is_dir() else [] + + # Skill payloads are ZIP files too, but they are dependencies inside a + # directory Bundle rather than standalone Agent Bundles. Do not attempt + # to parse them as archives that must contain agent.json. + for path in sorted(bundle_paths): + profile = path.relative_to(root).parts[0] + try: + bundle = _load_zip_bundle(profile, path) + if bundle is None: + continue + except (OSError, ValueError, zipfile.BadZipFile, json.JSONDecodeError) as exc: + logger.warning("Skipping official agent bundle %s: %s", path, exc) + continue + if bundle.name in seen_names: + raise ValueError(f"duplicate official agent bundle: {bundle.name}") + seen_names.add(bundle.name) + bundles.append(bundle) + for bundle_dir in sorted(bundle_dirs): + profile = bundle_dir.parent.relative_to(root).parts[0] + try: + data = _read_bundle_json(bundle_dir) + snapshot = _snapshot_from_json(data) + knowledge_bases = _load_kb_metadata(data) + skills = _load_skill_entries(bundle_dir, snapshot) + if skills: + snapshot = snapshot.model_copy(update={"skills": skills}) + kb_docs = _load_kb_documents(bundle_dir, data) + bundle = OfficialAgentBundle( + profile=profile, + name=bundle_dir.name, + snapshot=snapshot, + display_name=data.get("display_name"), + description=data.get("description"), + tags=data.get("tags"), + icon=data.get("icon"), + bundle_path=bundle_dir, + knowledge_bases=knowledge_bases, + knowledge_base_documents=kb_docs, + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + logger.warning("Skipping official agent bundle %s: %s", bundle_dir, exc) + continue + if bundle.name in seen_names: + raise ValueError(f"duplicate official agent bundle: {bundle.name}") + seen_names.add(bundle.name) + bundles.append(bundle) + return sorted(bundles, key=lambda item: (item.profile, item.name)) diff --git a/backend/services/official_agent_service.py b/backend/services/official_agent_service.py new file mode 100644 index 0000000000..66128d7985 --- /dev/null +++ b/backend/services/official_agent_service.py @@ -0,0 +1,1132 @@ +"""Official agent bundle discovery, installation-status listing and install. + +Official agents mirror the official-skills mechanism: platform bundles live in +``OFFICIAL_AGENTS_PATH`` as one JSON file per agent (``.json``), each +carrying a full :class:`AgentRepositorySnapshot` plus official card fields and +optional knowledge base seed documents. + +The install pipeline installs MCP servers, creates per-tenant knowledge bases +from seed documents, remaps the agent's KB tool references to the tenant's +generated index names, and imports the agent with its skills. +""" + +import base64 +import json +import logging +import os +import shutil +import tempfile +import zipfile +from typing import Dict, List, Optional + +from consts.const import OFFICIAL_AGENTS_PATH +from consts.model import ( + KnowledgeBaseResolution, + KnowledgeBaseSeedDoc, + ModelConnectStatusEnum, + OfficialAgentAgentInfo, + OfficialAgentBundle, + OfficialAgentInstallItem, + OfficialAgentInstallStep, + OfficialAgentListItem, + OfficialAgentMcpPreview, + SkillResolution, + SkillZipEntry, +) +from management.services.agent.naming import ( + check_agent_value_duplicate, + generate_unique_agent_value, +) + +logger = logging.getLogger("official_agent_service") + +# Tool classes that reference a knowledge base by params.index_names. +_KB_TOOL_CLASS_NAMES = frozenset({"KnowledgeBaseSearchTool", "DataMateSearchTool"}) + +# KB seed file suffixes treated as plain text (embedded via index_documents). +_TEXT_DOC_SUFFIXES = frozenset({".md", ".txt", ".markdown"}) + + +def _list_bundle_files() -> List[str]: + """Return sorted official bundle keys found under OFFICIAL_AGENTS_PATH. + + A bundle is either a directory (``/agent.json``) or a single JSON file + (``.json``) — mirroring the dual import-agent formats. + """ + if not os.path.isdir(OFFICIAL_AGENTS_PATH): + logger.warning( + "Official agents bundle directory not found: %s", + OFFICIAL_AGENTS_PATH, + ) + return [] + try: + names = set() + for root, directories, files in os.walk(OFFICIAL_AGENTS_PATH): + directories[:] = [directory for directory in directories if not directory.startswith(".")] + relative_root = os.path.relpath(root, OFFICIAL_AGENTS_PATH).replace("\\", "/") + if "agent.json" in files and relative_root != ".": + names.add(relative_root) + for filename in files: + if filename.lower() == "agent.json": + continue + if filename.lower().endswith(".json") and filename[:-5]: + relative_name = os.path.join(relative_root, filename[:-5]).replace("\\", "/") + names.add(relative_name.lstrip("./")) + elif filename.lower().endswith(".zip") and filename[:-4]: + relative_name = os.path.join(relative_root, filename[:-4]).replace("\\", "/") + names.add(relative_name.lstrip("./")) + return sorted(names) + except OSError as e: + logger.warning("Failed to list official agents bundle directory: %s", e) + return [] + + +def _attach_skills_from_dir(bundle: OfficialAgentBundle, dir_path: str) -> None: + """Attach skill ZIP payloads from ``/skills/*.zip``. + + The authoritative skill list comes from each agent's ``skill_names``; files + are read by name and base64-encoded, matching the import-agent behaviour. + """ + skills_dir = os.path.join(dir_path, "skills") + skill_names = sorted( + { + skill_name + for agent in bundle.agent_info.values() + for skill_name in (getattr(agent, "skill_names", None) or []) + if skill_name + } + ) + attached: List[SkillZipEntry] = [] + for skill_name in skill_names: + zip_path = os.path.join(skills_dir, f"{skill_name}.zip") + if not os.path.isfile(zip_path): + logger.warning( + "Official agent '%s' references skill '%s' but %s is missing", + bundle.name, + skill_name, + zip_path, + ) + continue + with open(zip_path, "rb") as f: + zip_bytes = f.read() + attached.append( + SkillZipEntry( + skill_name=skill_name, + skill_zip_base64=base64.b64encode(zip_bytes).decode("ascii"), + ) + ) + if attached: + bundle.skills = attached + + +def _attach_kb_docs_from_dir(bundle: OfficialAgentBundle, dir_path: str) -> None: + """Attach knowledge base seed documents from ``/kb//*``. + + Text files (.md/.txt) are read into ``content``; other files (docx/pdf/...) + are kept as ``file_path`` pointing at the real file so the install pipeline + can upload and process them like a normal knowledge base document. + """ + kb_dir = os.path.join(dir_path, "kb") + if not os.path.isdir(kb_dir): + return + for kb in bundle.knowledge_bases or []: + logical_dir = os.path.join(kb_dir, kb.logical_index_name) + if not os.path.isdir(logical_dir): + continue + docs: List[KnowledgeBaseSeedDoc] = [] + try: + for file_name in sorted(os.listdir(logical_dir)): + file_path = os.path.join(logical_dir, file_name) + if not os.path.isfile(file_path): + continue + suffix = os.path.splitext(file_name)[1].lower() + if suffix in _TEXT_DOC_SUFFIXES: + with open(file_path, encoding="utf-8") as f: + docs.append( + KnowledgeBaseSeedDoc( + file_name=file_name, + content=f.read(), + ) + ) + else: + # binary seed: keep the real file for the install pipeline + docs.append( + KnowledgeBaseSeedDoc( + file_name=file_name, + file_path=file_path, + ) + ) + except OSError as e: + logger.warning( + "Failed to read KB docs for '%s/%s': %s", + bundle.name, + kb.logical_index_name, + e, + ) + continue + if docs: + kb.documents = docs + + +def _load_bundle(name: str) -> Optional[OfficialAgentBundle]: + """Load a bundle by key, supporting both directory and single-file layouts. + + Directory layout: ``/agent.json`` + ``skills/`` + ``kb/`` (skills and + KB documents are attached from files). Single-file layout: ``.json`` + with skills/documents inline. The key is treated as the authoritative name. + """ + if ( + not name + or os.path.isabs(name) + or name in {".", ".."} + or any(part in {"", ".", ".."} for part in name.replace("\\", "/").split("/")) + ): + logger.warning("Skip official agent bundle with unsafe name: %r", name) + return None + + dir_path = os.path.join(OFFICIAL_AGENTS_PATH, name) + logger.info( + "Loading official agent bundle name=%r base_path=%r direct_path=%r", + name, + OFFICIAL_AGENTS_PATH, + dir_path, + ) + if not os.path.isdir(dir_path): + # Deployment resources may be grouped by profile, for example + # ``official-agents/general//agent.json``. The repository + # stores the Bundle directory name, so resolve it below any profile + # directory without exposing the host path to callers. + for root, directories, files in os.walk(OFFICIAL_AGENTS_PATH): + directories[:] = [ + directory + for directory in directories + if not directory.startswith(".") + ] + if os.path.basename(root) == name and "agent.json" in files: + dir_path = root + break + if os.path.isdir(dir_path) and os.path.isfile( + os.path.join(dir_path, "agent.json") + ): + try: + with open(os.path.join(dir_path, "agent.json"), encoding="utf-8") as f: + data = json.load(f) + bundle = OfficialAgentBundle.model_validate(data) + except (OSError, json.JSONDecodeError, ValueError) as e: + logger.warning( + "Skip invalid official agent bundle '%s' at '%s': %s", + name, + os.path.join(dir_path, "agent.json"), + e, + ) + return None + bundle.name = name + _attach_skills_from_dir(bundle, dir_path) + _attach_kb_docs_from_dir(bundle, dir_path) + return bundle + + single_path = os.path.join(OFFICIAL_AGENTS_PATH, f"{name}.json") + if not os.path.isfile(single_path): + for root, directories, files in os.walk(OFFICIAL_AGENTS_PATH): + directories[:] = [ + directory + for directory in directories + if not directory.startswith(".") + ] + candidate = os.path.join(root, f"{name}.json") + if os.path.isfile(candidate): + single_path = candidate + break + if os.path.isfile(single_path): + try: + with open(single_path, encoding="utf-8") as f: + data = json.load(f) + bundle = OfficialAgentBundle.model_validate(data) + except (OSError, json.JSONDecodeError, ValueError) as e: + logger.warning("Skip invalid official agent bundle '%s': %s", name, e) + return None + bundle.name = name + return bundle + + zip_path = os.path.join(OFFICIAL_AGENTS_PATH, f"{name}.zip") + if not os.path.isfile(zip_path): + for root, directories, files in os.walk(OFFICIAL_AGENTS_PATH): + directories[:] = [ + directory + for directory in directories + if not directory.startswith(".") + ] + candidate = os.path.join(root, f"{name}.zip") + if os.path.isfile(candidate): + zip_path = candidate + break + if os.path.isfile(zip_path): + extract_dir = tempfile.mkdtemp(prefix="nexent-official-agent-") + try: + with zipfile.ZipFile(zip_path) as archive: + root = os.path.realpath(extract_dir) + for member in archive.infolist(): + target = os.path.realpath(os.path.join(extract_dir, member.filename)) + if not (target == root or target.startswith(root + os.sep)): + raise ValueError("official agent ZIP contains unsafe path") + if member.is_dir(): + os.makedirs(target, exist_ok=True) + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + with archive.open(member) as source, open(target, "wb") as destination: + destination.write(source.read()) + with open(os.path.join(extract_dir, "agent.json"), encoding="utf-8") as f: + data = json.load(f) + bundle = OfficialAgentBundle.model_validate(data) + bundle.name = name + _attach_skills_from_dir(bundle, extract_dir) + _attach_kb_docs_from_dir(bundle, extract_dir) + return bundle + except (OSError, json.JSONDecodeError, ValueError, zipfile.BadZipFile) as e: + logger.warning("Skip invalid official agent ZIP '%s': %s", name, e) + shutil.rmtree(extract_dir, ignore_errors=True) + return None + + return None + + +def _find_installed_agent_id( + bundle: OfficialAgentBundle, + tenant_id: str, + user_id: Optional[str] = None, +) -> Optional[int]: + """Return whether the bundle's root agent already exists in the tenant. + + ``search_agent_id_by_agent_name`` raises ValueError when the agent is + absent, so absence is detected via the exception rather than a None return. + """ + root_agent = bundle.agent_info.get(str(bundle.agent_id)) + if root_agent is None: + return False + name = getattr(root_agent, "name", None) + if not name: + return False + + if user_id is not None: + from database.agent_db import query_all_agent_info_by_tenant_id + + for agent in query_all_agent_info_by_tenant_id(tenant_id): + if ( + agent.get("name") == name + and str(agent.get("created_by")) == str(user_id) + ): + return agent.get("agent_id") + return None + + from database.agent_db import search_agent_id_by_agent_name + + try: + return search_agent_id_by_agent_name(name, tenant_id) + except ValueError: + return None + + +def _is_agent_installed(bundle: OfficialAgentBundle, tenant_id: str) -> bool: + """Return whether the bundle's root agent already exists in the tenant.""" + return _find_installed_agent_id(bundle, tenant_id) is not None + + +async def _first_available_embedding_model_id(tenant_id: str) -> Optional[int]: + """Return the model_id of the first usable embedding model for a tenant. + + Matches the availability criteria used by knowledge base creation + (model_type in embedding/multi_embedding and connect_status available). + Returns None when the tenant has no usable embedding model. + """ + from services.model_management_service import list_models_for_tenant + + models = await list_models_for_tenant(tenant_id) + for model in models: + if ( + model.get("model_type") in ("embedding", "multi_embedding") + and model.get("connect_status") == ModelConnectStatusEnum.AVAILABLE.value + ): + return model.get("model_id") + return None + + +async def _has_available_embedding_model(tenant_id: str) -> bool: + """Return whether the tenant has a usable embedding model.""" + return await _first_available_embedding_model_id(tenant_id) is not None + + +async def _missing_model_types( + bundle: OfficialAgentBundle, + tenant_id: str, +) -> List[str]: + """Return the model types the tenant must configure before installing. + + Checks ``llm`` (any usable chat model — the agent cannot run without one), + ``embedding`` (when the bundle carries knowledge bases) and ``rerank`` + (when a KB search tool enables rerank). Returns a stable list of model type + strings; empty when the tenant has everything the bundle needs. + """ + from services.model_management_service import list_models_for_tenant + + models = await list_models_for_tenant(tenant_id) + available = { + m.get("model_type") + for m in models + if m.get("connect_status") == ModelConnectStatusEnum.AVAILABLE.value + } + + missing: List[str] = [] + + if not available.intersection(("llm", "vlm")): + missing.append("llm") + + if bundle.knowledge_bases and not available.intersection( + ("embedding", "multi_embedding") + ): + missing.append("embedding") + + if ( + bundle.knowledge_bases + and _kb_needs_rerank(bundle) + and "rerank" not in available + ): + missing.append("rerank") + + return missing + + +def _kb_needs_rerank(bundle: OfficialAgentBundle) -> bool: + """Return whether any KB search tool in the bundle enables rerank.""" + for agent in bundle.agent_info.values(): + for tool in agent.tools or []: + if tool.class_name not in _KB_TOOL_CLASS_NAMES: + continue + if (tool.params or {}).get("rerank") is True: + return True + return False + + +def _agent_infos(bundle: OfficialAgentBundle) -> List[OfficialAgentAgentInfo]: + """Return the bundle's agent name/display_name list (root + sub-agents).""" + infos: List[OfficialAgentAgentInfo] = [] + for agent in bundle.agent_info.values(): + infos.append( + OfficialAgentAgentInfo( + name=agent.name, + display_name=getattr(agent, "display_name", None), + ) + ) + return infos + + +def _mcp_previews( + bundle: OfficialAgentBundle, + tenant_id: str, +) -> List[OfficialAgentMcpPreview]: + """Return the bundle's MCP declarations with per-tenant install state. + + ``installed`` mirrors the install dedup rule: an MCP is considered installed + only when a server with the same name AND url already exists. + """ + from database.remote_mcp_db import get_mcp_server_by_name_and_tenant + + previews: List[OfficialAgentMcpPreview] = [] + for mcp in bundle.mcp_info or []: + existing_url = get_mcp_server_by_name_and_tenant( + mcp.mcp_server_name, tenant_id + ) + previews.append( + OfficialAgentMcpPreview( + mcp_server_name=mcp.mcp_server_name, + mcp_url=mcp.mcp_url, + installed=existing_url == mcp.mcp_url, + ) + ) + return previews + + +async def list_official_agents_with_status( + tenant_id: str, +) -> List[OfficialAgentListItem]: + """List all official agents with their installation status for a tenant. + + Status priority: installed > needs_model > installable. + """ + items: List[OfficialAgentListItem] = [] + for name in _list_bundle_files(): + bundle = _load_bundle(name) + if bundle is None: + continue + + has_knowledge = bool(bundle.knowledge_bases) + missing_models = await _missing_model_types(bundle, tenant_id) + + if _is_agent_installed(bundle, tenant_id): + status = "installed" + elif missing_models: + status = "needs_model" + else: + status = "installable" + + items.append( + OfficialAgentListItem( + name=bundle.name, + display_name=bundle.display_name, + description=bundle.description, + icon=bundle.icon, + tags=bundle.tags, + version_label=bundle.version_label, + status=status, + has_knowledge=has_knowledge, + mcp_count=len(bundle.mcp_info or []), + skill_count=len(bundle.skills or []), + kb_count=len(bundle.knowledge_bases or []), + missing_models=missing_models, + agents=_agent_infos(bundle), + mcps=_mcp_previews(bundle, tenant_id), + ) + ) + return items + + +# --------------------------------------------------------------------------- +# Installation +# --------------------------------------------------------------------------- + + +async def _install_mcp_servers( + bundle: OfficialAgentBundle, + tenant_id: str, + user_id: str, +) -> None: + """Install the bundle's MCP servers that are missing in the tenant. + + An MCP is skipped only when a server with the same name AND url already + exists (matching the agent-config import behaviour). A server whose name is + taken by a different URL is a genuine conflict and aborts the install with + a clear message, so the agent's tools never point at the wrong server. + + Official MCP endpoints are trusted platform-provided URLs, so health checks + are skipped. + """ + from database.remote_mcp_db import get_mcp_server_by_name_and_tenant + from services.remote_mcp_service import add_mcp_service + + for mcp in bundle.mcp_info or []: + existing_url = get_mcp_server_by_name_and_tenant( + mcp.mcp_server_name, tenant_id + ) + if existing_url == mcp.mcp_url: + logger.info( + "MCP server '%s' already exists for tenant %s with the same " + "URL, skipping", + mcp.mcp_server_name, + tenant_id, + ) + continue + + if existing_url: + raise ValueError( + f"MCP server name '{mcp.mcp_server_name}' already exists for " + f"tenant {tenant_id} but with a different URL " + f"('{existing_url}' != '{mcp.mcp_url}'). Remove or rename the " + f"existing server before installing." + ) + await add_mcp_service( + tenant_id=tenant_id, + user_id=user_id, + name=mcp.mcp_server_name, + description=None, + source="local", + server_url=mcp.mcp_url, + tags=[], + authorization_token=None, + container_config=None, + registry_json=None, + skip_health_check=True, + enabled=True, + ) + logger.info( + "Installed official MCP server '%s' for tenant %s", + mcp.mcp_server_name, + tenant_id, + ) + + +async def _create_knowledge_bases( + bundle: OfficialAgentBundle, + tenant_id: str, + user_id: str, + embedding_model_id: int, + authorization: str, + knowledge_base_resolutions: Optional[List[KnowledgeBaseResolution]] = None, +) -> Dict[str, str]: + """Create the bundle's knowledge bases for the tenant and index seed docs. + + Each knowledge base is created as an independent per-tenant instance (the + vector index name is generated per KB, so tenants never share indices). + Returns a mapping ``logical_index_name -> actual tenant index_name``. A KB + whose logical index name already exists in the tenant is reused unchanged. + """ + from database.group_db import query_groups_by_tenant + from database.knowledge_db import get_knowledge_record, update_knowledge_record + from utils.str_utils import convert_string_to_list + from management.services.knowledge_base.service import ElasticSearchService + from management.services.knowledge_base.common import ( + get_vector_db_core, + ) + from management.services.model.resolver import ( + get_embedding_model_by_id, + ) + + vdb_core = get_vector_db_core() + embedding_model, _ = get_embedding_model_by_id(tenant_id, embedding_model_id) + mapping: Dict[str, str] = {} + resolution_map = { + item.knowledge_name: item.action + for item in (knowledge_base_resolutions or []) + } + + for kb in bundle.knowledge_bases or []: + logical = kb.logical_index_name + kb_name = kb.display_name or logical + # Reuse by display name (matching the KB page's per-tenant uniqueness), + # not by the logical key: logical keys never match a real index_name, so + # name-based reuse avoids creating a duplicate KB on re-install. + existing = get_knowledge_record( + {"knowledge_name": kb_name, "tenant_id": tenant_id} + ) + action = resolution_map.get(kb_name, "reuse") + if existing and action == "reuse": + existing_index = existing.get("index_name") or logical + tenant_groups = query_groups_by_tenant( + tenant_id, page=None, page_size=None + ).get("groups", []) + tenant_group_ids = sorted( + int(group["group_id"]) + for group in tenant_groups + if group.get("group_id") is not None + ) + existing_group_ids = set( + convert_string_to_list(existing.get("group_ids")) + ) + if ( + set(tenant_group_ids) != existing_group_ids + or str(existing.get("ingroup_permission") or "").upper() + != "READ_ONLY" + ): + update_knowledge_record( + { + "index_name": existing_index, + "group_ids": ",".join(str(group) for group in tenant_group_ids), + "ingroup_permission": "READ_ONLY", + "updated_by": user_id, + } + ) + logger.info( + "Expanded official knowledge base '%s' visibility for tenant " + "%s to groups %s", + kb_name, + tenant_id, + tenant_group_ids, + ) + logger.info( + "Knowledge base '%s' already exists for tenant %s, reusing " + "(index %s)", + kb_name, + tenant_id, + existing_index, + ) + mapping[logical] = existing_index + continue + + if existing and action == "create_new": + candidate = f"{kb_name} 副本" + suffix = 2 + while get_knowledge_record( + {"knowledge_name": candidate, "tenant_id": tenant_id} + ): + candidate = f"{kb_name} 副本 {suffix}" + suffix += 1 + kb_name = candidate + + created = ElasticSearchService.create_knowledge_base( + knowledge_name=kb_name, + embedding_dim=None, + vdb_core=vdb_core, + user_id=user_id, + tenant_id=tenant_id, + embedding_model_id=embedding_model_id, + ingroup_permission="READ_ONLY", + group_ids=[ + int(group["group_id"]) + for group in query_groups_by_tenant( + tenant_id, page=None, page_size=None + ).get("groups", []) + if group.get("group_id") is not None + ], + ) + actual_index = created["id"] + + if kb.documents: + text_docs = [doc for doc in kb.documents if doc.content] + file_docs = [doc for doc in kb.documents if doc.file_path] + if text_docs: + data = [ + { + "content": doc.content, + "path_or_url": doc.file_name, + "source_type": "local", + "filename": doc.file_name, + "metadata": {"title": doc.file_name}, + } + for doc in text_docs + ] + ElasticSearchService.index_documents( + embedding_model=embedding_model, + index_name=actual_index, + data=data, + vdb_core=vdb_core, + model_id=embedding_model_id, + ) + logger.info( + "Indexed %d text seed document(s) into knowledge base '%s'", + len(text_docs), + actual_index, + ) + if file_docs: + await _index_binary_docs( + actual_index, + file_docs, + tenant_id=tenant_id, + user_id=user_id, + embedding_model_id=embedding_model_id, + authorization=authorization, + ) + logger.info( + "Uploaded %d file seed document(s) for processing into " + "knowledge base '%s'", + len(file_docs), + actual_index, + ) + mapping[logical] = actual_index + + return mapping + + +async def _index_binary_docs( + index_name: str, + docs: List[KnowledgeBaseSeedDoc], + *, + tenant_id: str, + user_id: str, + embedding_model_id: int, + authorization: str, +) -> None: + """Upload binary seed docs (docx/pdf/...) and trigger the platform pipeline. + + Reuses the same file pipeline as the knowledge base page: upload to MinIO, + then trigger async data processing which parses, chunks and embeds the + documents. Processing runs in the background; the KB is searchable once it + completes. + """ + from io import BytesIO + + from starlette.datastructures import UploadFile + + from consts.model import ProcessParams + from services.file_management_service import upload_files_impl + from utils.file_management_utils import trigger_data_process + + files_to_process: List[Dict[str, str]] = [] + for doc in docs: + with open(doc.file_path, "rb") as f: + file_bytes = f.read() + upload_file = UploadFile( + filename=os.path.basename(doc.file_path), + file=BytesIO(file_bytes), + ) + _, uploaded_file_paths, _ = await upload_files_impl( + destination="minio", + file=[upload_file], + folder="knowledge_base", + index_name=index_name, + user_id=user_id, + uploader_tenant_id=tenant_id, + ) + if not uploaded_file_paths: + logger.warning( + "Failed to upload KB seed file '%s' for index %s", + doc.file_name, + index_name, + ) + continue + files_to_process.append( + { + "path_or_url": uploaded_file_paths[0], + "filename": doc.file_name, + } + ) + + if files_to_process: + await trigger_data_process( + files_to_process, + ProcessParams( + chunking_strategy="basic", + source_type="minio", + index_name=index_name, + model_id=embedding_model_id, + authorization=authorization, + ), + ) + + +def _remap_kb_refs( + bundle: OfficialAgentBundle, + mapping: Dict[str, str], +) -> None: + """Rewrite KB tool ``params.index_names`` from logical keys to real index names. + + The bundle's tools reference knowledge bases by their logical index names; + after per-tenant KB creation those names must point at the generated tenant + index names or the search tool would look up a non-existent index. + """ + for agent in bundle.agent_info.values(): + for tool in agent.tools or []: + if tool.class_name not in _KB_TOOL_CLASS_NAMES: + continue + params = tool.params or {} + index_names = params.get("index_names") + if isinstance(index_names, list): + params["index_names"] = [ + mapping.get(str(index_name), index_name) + for index_name in index_names + ] + + +def _update_existing_agent_kb_refs( + bundle: OfficialAgentBundle, + agent_id: int, + tenant_id: str, + user_id: str, +) -> None: + """Repair KB tool references on an Agent from an earlier partial install.""" + from consts.model import ToolInstanceInfoRequest + from database.tool_db import ( + create_or_update_tool_by_tool_info, + query_all_tools, + query_tool_instances_by_agent_id, + ) + + bundle_tools = { + (tool.class_name, tool.source): tool + for agent in bundle.agent_info.values() + for tool in agent.tools or [] + if tool.class_name in _KB_TOOL_CLASS_NAMES + } + if not bundle_tools: + return + + tenant_tools = { + tool.get("tool_id"): tool for tool in query_all_tools(tenant_id=tenant_id) + } + for instance in query_tool_instances_by_agent_id(agent_id, tenant_id): + tenant_tool = tenant_tools.get(instance.get("tool_id")) or {} + key = (tenant_tool.get("class_name"), tenant_tool.get("source")) + bundle_tool = bundle_tools.get(key) + tool_id = instance.get("tool_id") + if bundle_tool is None or tool_id is None: + continue + create_or_update_tool_by_tool_info( + ToolInstanceInfoRequest( + tool_id=tool_id, + agent_id=agent_id, + enabled=instance.get("enabled", True), + params=bundle_tool.params, + ), + tenant_id=tenant_id, + user_id=user_id, + ) + logger.info( + "Repaired knowledge-base tool reference for existing agent_id=%s", + agent_id, + ) + + +async def _install_bundle( + bundle: OfficialAgentBundle, + tenant_id: str, + user_id: str, + authorization: str, + embedding_model_id: Optional[int] = None, + skill_resolutions: Optional[List[SkillResolution]] = None, + steps: Optional[List[OfficialAgentInstallStep]] = None, + existing_agent_id: Optional[int] = None, + knowledge_base_resolutions: Optional[List[KnowledgeBaseResolution]] = None, +) -> Optional[int]: + """Install one official agent bundle, returning the new main agent id. + + Order matters: MCP servers first (with the tool list refresh folded in), + then skills, then knowledge bases (with KB tool reference remapping), then + the agent import (which validates that every referenced tool exists). + + Skill names already present in the tenant are reused (not re-created), so + installing into a tenant that already has a same-name skill succeeds. + + Each step's outcome (ok/failed + reason) is appended to ``steps`` so the + caller can surface exactly where an install failed. + """ + if steps is None: + steps = [] + + async def _run_step(name: str, coroutine): + try: + result = await coroutine + except Exception as e: + steps.append( + OfficialAgentInstallStep(name=name, status="failed", message=str(e)) + ) + raise + steps.append(OfficialAgentInstallStep(name=name, status="ok")) + return result + + async def _install_mcp_and_tools(): + await _install_mcp_servers(bundle, tenant_id, user_id) + from services.tool_configuration_service import update_tool_list + + await update_tool_list(tenant_id=tenant_id, user_id=user_id) + + await _run_step("mcp", _install_mcp_and_tools()) + + from management.services.agent.service import ( + import_agent_impl, + import_agent_with_skills_impl, + ) + + if bundle.knowledge_bases: + if embedding_model_id is None: + embedding_model_id = await _first_available_embedding_model_id(tenant_id) + if embedding_model_id is None: + raise ValueError( + "Official agent '%s' carries knowledge bases but the tenant has " + "no available embedding model" % bundle.name + ) + kb_mapping = await _run_step( + "knowledge_base", + _create_knowledge_bases( + bundle, + tenant_id, + user_id, + embedding_model_id, + authorization=authorization, + knowledge_base_resolutions=knowledge_base_resolutions, + ), + ) + _remap_kb_refs(bundle, kb_mapping) + if existing_agent_id is not None: + _update_existing_agent_kb_refs( + bundle, + existing_agent_id, + tenant_id, + user_id, + ) + + if bundle.skills and existing_agent_id is None: + agent_id_mapping = await _run_step( + "agent", + import_agent_with_skills_impl( + bundle, + bundle.skills, + authorization, + skill_resolutions=skill_resolutions, + ), + ) + elif existing_agent_id is not None: + steps.append( + OfficialAgentInstallStep( + name="agent", + status="ok", + message="agent already exists; dependencies ensured", + ) + ) + return existing_agent_id + else: + agent_id_mapping = await _run_step( + "agent", + import_agent_impl( + bundle, + authorization, + ), + ) + + return agent_id_mapping.get(bundle.agent_id) + + +def _apply_install_options( + bundle: OfficialAgentBundle, + renames: Optional[Dict[str, str]] = None, + model_ids: Optional[Dict[str, int]] = None, +) -> None: + """Apply user-selected renames and model ids to a loaded bundle in place. + + ``renames`` maps an existing agent name to a new name; every agent in the + bundle whose name is a key is renamed before import. ``model_ids`` maps a + bundle key to a tenant model_id applied to every agent in the bundle + (unified, mirroring the agent-config import wizard), resolved against the + tenant catalog by the import flow. + """ + for agent in bundle.agent_info.values(): + new_name = (renames or {}).get(agent.name) + if new_name: + agent.name = new_name + if model_ids and bundle.name in model_ids: + selected = model_ids[bundle.name] + for agent in bundle.agent_info.values(): + agent.model_ids = [selected] + + +async def install_official_agents( + agent_names: List[str], + tenant_id: str, + user_id: str, + authorization: str, + renames: Optional[Dict[str, str]] = None, + model_ids: Optional[Dict[str, int]] = None, + embedding_model_ids: Optional[Dict[str, int]] = None, + skill_resolutions: Optional[List[SkillResolution]] = None, + knowledge_base_resolutions: Optional[List[KnowledgeBaseResolution]] = None, +) -> List[OfficialAgentInstallItem]: + """Install the requested official agents for a tenant. + + Each agent is processed independently: a failure on one does not affect the + others. Agents already present in the tenant are skipped, unless the root + agent name is being renamed via ``renames`` (the user explicitly asked for a + differently-named copy). + + ``renames`` maps an existing agent name inside a bundle to a new name; + ``model_ids`` maps a bundle key to a tenant LLM model_id for the root agent; + ``embedding_model_ids`` maps a bundle key to a tenant embedding model_id used + to create its knowledge bases (falls back to the first available embedding + model when omitted). + """ + results: List[OfficialAgentInstallItem] = [] + for name in agent_names: + bundle = _load_bundle(name) + if bundle is None: + results.append( + OfficialAgentInstallItem( + name=name, + status="not_found", + message=f"Official agent bundle '{name}' not found", + ) + ) + continue + + logger.info( + "Preparing official agent '%s' for tenant %s: agents=%d skills=%d " + "knowledge_bases=%d documents=%d", + name, + tenant_id, + len(bundle.agent_info), + len(bundle.skills or []), + len(bundle.knowledge_bases or []), + sum(len(kb.documents or []) for kb in bundle.knowledge_bases or []), + ) + + root_agent = bundle.agent_info.get(str(bundle.agent_id)) + root_name = getattr(root_agent, "name", None) if root_agent else None + root_renamed = bool(renames) and bool(root_name) and root_name in renames + existing_agent_id = None + from database.agent_db import query_all_agent_info_by_tenant_id + + agents_cache = query_all_agent_info_by_tenant_id(tenant_id) + if root_name and not root_renamed and check_agent_value_duplicate( + "name", root_name, tenant_id, agents_cache=agents_cache + ): + renames = dict(renames or {}) + renames[root_name] = generate_unique_agent_value( + "name", root_name, tenant_id, agents_cache=agents_cache + ) + logger.info( + "Official agent '%s' name already exists; creating unique copy name=%s", + name, + renames[root_name], + ) + + if root_agent is not None: + from database.user_tenant_db import get_user_tenant_by_user_id + + user_record = get_user_tenant_by_user_id(user_id) or {} + user_email = str(user_record.get("user_email") or "").strip() + current_display_name = getattr(root_agent, "display_name", None) or root_name + labeled_display_name = ( + f"{current_display_name}({user_email})" + if user_email + else current_display_name + ) + if check_agent_value_duplicate( + "display_name", labeled_display_name, tenant_id, agents_cache=agents_cache + ): + labeled_display_name = generate_unique_agent_value( + "display_name", + labeled_display_name, + tenant_id, + agents_cache=agents_cache, + ) + root_agent.display_name = labeled_display_name + + missing_models = await _missing_model_types(bundle, tenant_id) + if missing_models: + results.append( + OfficialAgentInstallItem( + name=name, + status="needs_model", + missing_models=missing_models, + message="缺少模型: " + ", ".join(missing_models), + ) + ) + continue + + _apply_install_options(bundle, renames, model_ids) + + embedding_model_id: Optional[int] = None + if bundle.knowledge_bases: + embedding_model_id = (embedding_model_ids or {}).get(name) + if embedding_model_id is None: + embedding_model_id = await _first_available_embedding_model_id( + tenant_id + ) + + steps: List[OfficialAgentInstallStep] = [] + try: + agent_id = await _install_bundle( + bundle, + tenant_id, + user_id, + authorization, + embedding_model_id=embedding_model_id, + skill_resolutions=skill_resolutions, + knowledge_base_resolutions=knowledge_base_resolutions, + steps=steps, + existing_agent_id=existing_agent_id, + ) + results.append( + OfficialAgentInstallItem( + name=name, + status=("already_installed" if existing_agent_id else "installed"), + steps=steps, + agent_id=agent_id, + ) + ) + except Exception as e: + logger.exception( + "Failed to install official agent '%s' for tenant %s", + name, + tenant_id, + ) + results.append( + OfficialAgentInstallItem( + name=name, status="failed", message=str(e), steps=steps + ) + ) + return results diff --git a/backend/services/official_agent_sync_service.py b/backend/services/official_agent_sync_service.py new file mode 100644 index 0000000000..ff37ff18b0 --- /dev/null +++ b/backend/services/official_agent_sync_service.py @@ -0,0 +1,171 @@ +"""Synchronize external official agent bundles into the platform repository.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from consts.agent_repository import STATUS_SHARED +from consts.const import ( + SYSTEM_TENANT_ID, + SYSTEM_USER_ID, + OFFICIAL_AGENTS_PATH, + OFFICIAL_AGENT_PROFILES, +) + +from database.agent_db import create_agent, find_agent_id_by_agent_name +from database.agent_repository_db import upsert_agent_repository_record +from database.agent_repository_db import update_agent_repository_by_id +from services.official_agent_bundle_service import ( + OfficialAgentBundle, + load_official_bundles, + parse_official_agent_profiles, +) + +logger = logging.getLogger("official_agent_sync_service") +_sync_lock = asyncio.Lock() + + +def _source_agent_payload(agent: Any) -> dict[str, Any]: + return { + "name": agent.name, + "display_name": agent.display_name, + "description": agent.description, + "author": agent.author, + "max_steps": agent.max_steps, + "provide_run_summary": agent.provide_run_summary, + "allow_chat_metadata": agent.allow_chat_metadata, + "verification_config": agent.verification_config, + "context_policy": agent.context_policy, + "duty_prompt": agent.duty_prompt, + "constraint_prompt": agent.constraint_prompt, + "few_shots_prompt": agent.few_shots_prompt, + "enabled": agent.enabled, + "model_ids": agent.model_ids, + "business_logic_model_id": agent.business_logic_model_id, + "business_logic_model_name": agent.business_logic_model_name, + "prompt_template_id": agent.prompt_template_id, + "prompt_template_name": agent.prompt_template_name, + "greeting_message": agent.greeting_message, + "example_questions": agent.example_questions, + } + + +def _find_or_create_source_agent(agent: Any) -> int: + existing_agent_id = find_agent_id_by_agent_name( + agent.name, SYSTEM_TENANT_ID + ) + if existing_agent_id is not None: + return int(existing_agent_id) + return int(create_agent( + _source_agent_payload(agent), + tenant_id=SYSTEM_TENANT_ID, + user_id=SYSTEM_USER_ID, + )["agent_id"]) + + +def _materialize_snapshot(bundle: OfficialAgentBundle): + mapping: dict[int, int] = {} + for source_id, agent in bundle.snapshot.agent_info.items(): + mapping[int(source_id)] = _find_or_create_source_agent(agent) + + agent_info = {} + for source_id, agent in bundle.snapshot.agent_info.items(): + remapped = agent.model_copy(update={ + "agent_id": mapping[int(source_id)], + "tenant_id": SYSTEM_TENANT_ID, + "managed_agents": [mapping[item] for item in agent.managed_agents], + }) + agent_info[str(mapping[int(source_id)])] = remapped + + return bundle.snapshot.model_copy( + update={ + "agent_id": mapping[bundle.snapshot.agent_id], + "agent_info": agent_info, + } + ) + + +def _sync_bundle(bundle: OfficialAgentBundle) -> dict[str, Any]: + snapshot = _materialize_snapshot(bundle) + root = snapshot.agent_info[str(snapshot.agent_id)] + # Keep the official bundle's knowledge-base declarations in the repository + # snapshot as well as the tool's logical index references. The logical + # name (for example ``kb-1``) is only an internal remapping key; the + # bundle's ``display_name`` is the name that must be shown to users. + snapshot_payload = snapshot.model_dump(mode="json") + snapshot_payload["knowledge_bases"] = [ + knowledge_base.model_dump(mode="json") + for knowledge_base in (bundle.knowledge_bases or []) + ] + repository_data = { + "agent_id": snapshot.agent_id, + "version_no": getattr(root, "version_no", 1) or 1, + # The repository name is the Bundle directory key. It is the stable + # value used later to reload the mounted official Bundle. + "name": bundle.name, + "display_name": bundle.display_name or root.display_name, + "description": bundle.description or root.description, + # Official repository cards are published by Nexent, regardless of + # the author metadata carried by an exported source Agent. + "author": "Nexent", + "submitted_by": SYSTEM_USER_ID, + "version_name": "Official", + "agent_info_json": snapshot_payload, + "status": STATUS_SHARED, + "tags": bundle.tags or [], + "icon": bundle.icon, + "tool_count": sum(len(agent.tools) for agent in snapshot.agent_info.values()), + "content": "Official Nexent agent", + } + repository_id, updated = upsert_agent_repository_record( + repository_data, + publisher_tenant_id=SYSTEM_TENANT_ID, + publisher_user_id=SYSTEM_USER_ID, + ) + update_agent_repository_by_id( + repository_id=repository_id, + publisher_tenant_id=SYSTEM_TENANT_ID, + user_id=SYSTEM_USER_ID, + # Official bundles are file-backed templates. Re-synchronizing the + # same bundle/version must refresh its card metadata and snapshot too; + # otherwise edits to agent.json remain invisible because the generic + # repository upsert intentionally only refreshes status for an + # unchanged version. + updates={ + "name": bundle.name, + "display_name": repository_data["display_name"], + "description": repository_data["description"], + "author": repository_data["author"], + "tags": repository_data["tags"], + "tool_count": repository_data["tool_count"], + "version_name": repository_data["version_name"], + "icon": repository_data["icon"], + "version_no": repository_data["version_no"], + "agent_info_json": repository_data["agent_info_json"], + "status": repository_data["status"], + "content": repository_data["content"], + }, + ) + return {"name": bundle.name, "agent_repository_id": repository_id, "updated": updated} + + +async def sync_official_agents( + *, + base_dir: str = OFFICIAL_AGENTS_PATH, + profiles: str | list[str] = OFFICIAL_AGENT_PROFILES, +) -> list[dict[str, Any]]: + """Synchronize selected bundles; one broken bundle does not abort others.""" + async with _sync_lock: + selected = parse_official_agent_profiles(profiles) + bundles = load_official_bundles(base_dir, selected) + results: list[dict[str, Any]] = [] + for bundle in bundles: + try: + result = _sync_bundle(bundle) + results.append(result) + logger.info("Synchronized official agent bundle: %s", bundle.name) + except Exception: + logger.exception("Failed to synchronize official agent bundle: %s", bundle.name) + return results diff --git a/backend/services/providers/openai_provider.py b/backend/services/providers/openai_provider.py index ef71ce3f86..059a79bc9d 100644 --- a/backend/services/providers/openai_provider.py +++ b/backend/services/providers/openai_provider.py @@ -6,12 +6,8 @@ model list annotated with the canonical fields expected downstream. """ -import asyncio -import ipaddress import logging -import socket from typing import Dict, List -from urllib.parse import urlsplit import httpx @@ -23,86 +19,6 @@ logger = logging.getLogger("model_provider") -# Documented local-LLM exemption: operators may point at a local -# OpenAI-compatible server. Loopback is not a cross-origin target. -_LOCAL_LLM_EXEMPT_HOSTS = {"localhost", "127.0.0.1", "::1"} - - -def _reject_non_public_ip(ip) -> None: - """Raise when an address belongs to a non-routable/reserved range.""" - if ( - ip.is_private - or ip.is_loopback - or ip.is_link_local - or ip.is_multicast - or ip.is_reserved - or ip.is_unspecified - ): - raise ValueError( - "Provider base_url points to a private/reserved network host" - ) - - -def _validate_provider_base_url(base_url: str) -> str: - """Reject URLs that must never be fetched server-side (SSRF guard). - - The base_url comes from operator-supplied provider configuration, so a - crafted value could otherwise point the fetch at internal endpoints - (cloud metadata, loopback services, link-local routers). Rules: - - scheme must be http or https - - a host must be present - - loopback / link-local / private-network literal IPs are rejected. Local - development against a local LLM is intentionally still allowed for - 127.0.0.1/localhost via the documented exemption below. - - Returns the lower-cased hostname for follow-up DNS resolution checks. - """ - parsed = urlsplit(base_url) - if parsed.scheme not in ("http", "https"): - raise ValueError(f"Unsupported provider base_url scheme: {parsed.scheme!r}") - if not parsed.hostname: - raise ValueError("Provider base_url has no host") - - host = parsed.hostname.lower() - if host in _LOCAL_LLM_EXEMPT_HOSTS: - return host - - try: - ip = ipaddress.ip_address(host) - except ValueError: - return host # plain DNS name — resolved and checked separately - - _reject_non_public_ip(ip) - return host - - -def _resolve_host_ips(host: str) -> List[str]: - """Resolve a hostname to its literal IP addresses (blocking).""" - infos = socket.getaddrinfo(host, None) - return [info[4][0] for info in infos] - - -async def _assert_resolved_ips_public(host: str) -> None: - """Resolve the DNS name and reject any non-public resolved address. - - The hostname-string checks in _validate_provider_base_url cannot see what - a DNS name resolves to, so a domain pointing at a cloud-metadata endpoint - or an internal 10.x service would otherwise slip through. Resolving here - and validating every returned address closes that path. The residual - time-of-check/time-of-use window (DNS rebinding) is an accepted trade-off: - fully closing it would require pinning the resolved IP into the transport, - which httpx does not support. - """ - loop = asyncio.get_running_loop() - try: - addresses = await loop.run_in_executor(None, _resolve_host_ips, host) - except socket.gaierror as exc: - raise ValueError( - f"Provider base_url host cannot be resolved: {host}" - ) from exc - for address in addresses: - _reject_non_public_ip(ipaddress.ip_address(address)) - class OpenAICompatibleProvider(AbstractModelProvider): """Fetch models from any OpenAI-compatible ``/v1/models`` endpoint.""" @@ -117,13 +33,6 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: # everyone. ssl_verify: bool = provider_config.get("ssl_verify", True) - # SSRF guard: reject schemes without a host, non-routable literal - # IPs, and DNS names that resolve into private/reserved ranges - # before the operator-supplied URL is fetched. - host = _validate_provider_base_url(base_url) - if host not in _LOCAL_LLM_EXEMPT_HOSTS: - await _assert_resolved_ips_public(host) - # Normalise the base URL: strip trailing slashes, ensure it ends # with the ``/models`` path. url = base_url.rstrip("/") diff --git a/backend/services/quota_service.py b/backend/services/quota_service.py index bde38b360d..92fe9f6a52 100644 --- a/backend/services/quota_service.py +++ b/backend/services/quota_service.py @@ -89,11 +89,15 @@ def _is_displayable_tenant_id( def _gb_to_bytes(gb: int) -> int: """Convert integer GB to bytes.""" + if gb < 0: + raise ValueError(f"GB value must be non-negative, got {gb}") return gb * GB def _mb_to_bytes(mb: int) -> int: """Convert integer MB to bytes.""" + if mb < 0: + raise ValueError(f"MB value must be non-negative, got {mb}") return mb * MB @@ -232,15 +236,39 @@ def set_warning_config( warning_pct: Optional[int] = None, critical_pct: Optional[int] = None, ) -> Dict[str, Any]: - """Set warning thresholds. Validates 1-100 range.""" + """Set warning thresholds. Validates 1-100 range and warning < critical.""" if warning_pct is not None: if not 1 <= warning_pct <= 100: raise ValueError(f"warning_pct must be 1-100, got {warning_pct}") - self._set_tenant_config(KEY_WARNING_THRESHOLD_PCT, str(warning_pct)) if critical_pct is not None: if not 1 <= critical_pct <= 100: raise ValueError(f"critical_pct must be 1-100, got {critical_pct}") + + # Reject inverted thresholds against the values effective after update. + effective_warning = ( + warning_pct + if warning_pct is not None + else self.get_warning_config().get("warning_threshold_pct") + ) + effective_critical = ( + critical_pct + if critical_pct is not None + else self.get_warning_config().get("critical_threshold_pct") + ) + if ( + effective_warning is not None + and effective_critical is not None + and effective_warning >= effective_critical + ): + raise ValueError( + f"warning_threshold_pct ({effective_warning}) must be lower than " + f"critical_threshold_pct ({effective_critical})" + ) + + if warning_pct is not None: + self._set_tenant_config(KEY_WARNING_THRESHOLD_PCT, str(warning_pct)) + if critical_pct is not None: self._set_tenant_config(KEY_CRITICAL_THRESHOLD_PCT, str(critical_pct)) if enabled is not None: diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index c78e85f321..c80497a0bb 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -4,10 +4,17 @@ import asyncio import socket import random +from contextlib import AsyncExitStack from typing import Awaitable, Callable from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport, SSETransport -from consts.const import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ, NEXENT_MCP_DOCKER_IMAGE +from consts.const import ( + CAN_EDIT_ALL_USER_ROLES, + MCP_REQUEST_TIMEOUT_SECONDS, + NEXENT_MCP_DOCKER_IMAGE, + PERMISSION_EDIT, + PERMISSION_READ, +) from consts.exceptions import ( MCPConnectionError, MCPNameIllegal, @@ -48,9 +55,6 @@ logger = logging.getLogger("remote_mcp_service") -MCP_HEALTH_CHECK_TIMEOUT_SECONDS = 10 - - def _iter_exception_chain(exc: BaseException): seen: set[int] = set() current: BaseException | None = exc @@ -129,16 +133,17 @@ async def _mcp_protocol_health_check(url_stripped: str, headers: dict) -> list[s async def list_mcp_tools() -> list: client = Client(transport=transport) - async with client: + async with AsyncExitStack() as stack: + await asyncio.wait_for( + stack.enter_async_context(client), + timeout=MCP_REQUEST_TIMEOUT_SECONDS, + ) # Verify the server can actually serve tools. # This exercises API key validation and end-to-end connectivity, # unlike is_connected() which only checks the initialize handshake. return await client.list_tools() - tools_result = await asyncio.wait_for( - list_mcp_tools(), - timeout=MCP_HEALTH_CHECK_TIMEOUT_SECONDS, - ) + tools_result = await list_mcp_tools() return [t.name for t in tools_result] if tools_result else [] except BaseException as e: logger.debug(f"MCP protocol health check failed: {e}") @@ -175,9 +180,16 @@ async def _mcp_protocol_connect(url_stripped: str, headers: dict) -> bool: httpx_client_factory=create_httpx_client, ) - client = Client(transport=transport) - async with client: - return client.is_connected() + async def connect_client() -> bool: + client = Client(transport=transport) + async with AsyncExitStack() as stack: + await asyncio.wait_for( + stack.enter_async_context(client), + timeout=MCP_REQUEST_TIMEOUT_SECONDS, + ) + return client.is_connected() + + return await connect_client() except Exception as e: logger.debug(f"MCP protocol connect handshake failed: {e}") return False @@ -454,7 +466,11 @@ async def add_mcp_service( if is_api: # Register OpenAPI service (same as agent config flow) try: - from services.tool_configuration_service import import_openapi_service, _refresh_openapi_services_in_mcp + from services.tool_configuration_service import ( + import_openapi_service, + _refresh_openapi_services_in_mcp, + update_tool_list, + ) import_openapi_service( service_name=name, openapi_json=resolved_config_json, @@ -466,6 +482,9 @@ async def add_mcp_service( force_update=True, ) _refresh_openapi_services_in_mcp(tenant_id) + # Keep the persisted tool catalog in sync with the tenant MCP runtime. + # The agent configuration page reads /tool/list from this catalog. + await update_tool_list(tenant_id=tenant_id, user_id=user_id) except Exception as exc: logger.warning(f"Failed to register OpenAPI service '{name}': {exc}") # Extract tool names from OpenAPI spec for display @@ -1038,11 +1057,39 @@ async def delete_mcp_service( except Exception as exc: logger.warning(f"Failed to stop container: {exc}, but continue to delete MCP record") + is_openapi_service = ( + isinstance(current_record.get("config_json"), dict) + and "openapi" in current_record["config_json"] + ) + + if is_openapi_service: + # API-to-MCP services have a separate service record and all their + # persisted tools use the shared ``outer-apis`` usage value. + try: + from services.tool_configuration_service import ( + delete_openapi_service, + _refresh_openapi_services_in_mcp, + ) + + delete_openapi_service( + service_name=current_record.get("mcp_name") or "", + tenant_id=tenant_id, + user_id=user_id, + ) + _refresh_openapi_services_in_mcp(tenant_id) + except Exception as exc: + logger.warning( + f"Failed to remove API-to-MCP service '{current_record.get('mcp_name')}': {exc}" + ) # Hide the deleted MCP's tools and remove their editable agent draft bindings. # Cleanup must succeed before the MCP record is deleted to avoid stale bindings. set_mcp_tools_unavailable( tenant_id=tenant_id, - mcp_server_name=current_record.get("mcp_name") or "", + mcp_server_name=( + "outer-apis" + if is_openapi_service + else current_record.get("mcp_name") or "" + ), user_id=user_id, ) diff --git a/backend/services/repository_import_precheck.py b/backend/services/repository_import_precheck.py index a8321d0f0e..59e4fa306a 100644 --- a/backend/services/repository_import_precheck.py +++ b/backend/services/repository_import_precheck.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple from consts.model import ( + ModelConnectStatusEnum, RepositoryImportPrecheckResponse, RepositoryImportRequirementItem, ToolSourceEnum, @@ -22,6 +23,7 @@ from database.remote_mcp_db import get_mcp_server_by_name_and_tenant from database.tool_db import query_all_tools from utils.skill_import_utils import generate_available_copy_skill_name +from utils.agent_transfer_utils import AgentToolImportError, validate_import_tool_params _KB_TOOL_CLASS_NAMES = frozenset({ "KnowledgeBaseSearchTool", @@ -74,6 +76,23 @@ def _check_kb_available(index_name: str, tenant_id: str) -> Tuple[bool, Optional return True, None +def _check_kb_embedding_available( + record: Dict[str, Any], + tenant_id: str, +) -> Tuple[bool, Optional[str]]: + """Check that an existing official KB still has a usable tenant model.""" + model_id = record.get("embedding_model_id") + model = get_model_by_model_id(model_id, tenant_id) if model_id else None + if not model: + return False, _REASON_MODEL_UNAVAILABLE + + connect_status = ModelConnectStatusEnum.get_value(model.get("connect_status")) + if connect_status != ModelConnectStatusEnum.AVAILABLE.value: + return False, _REASON_MODEL_UNAVAILABLE + + return True, None + + def _check_mcp_available(server_name: str, tenant_id: str) -> Tuple[bool, Optional[str]]: if not server_name or not str(server_name).strip(): return False, _REASON_MCP_NOT_FOUND @@ -179,6 +198,11 @@ def _extract_knowledge_bases( tenant_id: str, ) -> List[Tuple[str, str, Optional[str]]]: """Return (key, display_name, description) tuples for knowledge bases.""" + bundle_kb_metadata = { + str(getattr(kb, "logical_index_name", "")): kb + for kb in (getattr(snapshot, "knowledge_bases", None) or []) + if getattr(kb, "logical_index_name", None) + } index_names: Set[str] = set() for agent in snapshot.agent_info.values(): agent_data = _agent_dict(agent) @@ -200,11 +224,21 @@ def _extract_knowledge_bases( ) items: List[Tuple[str, str, Optional[str]]] = [] for index_name in sorted(index_names): - display_name = name_map.get(index_name) or index_name + bundle_kb = bundle_kb_metadata.get(index_name) + display_name = ( + getattr(bundle_kb, "display_name", None) + if bundle_kb is not None + else None + ) or name_map.get(index_name) or index_name + description = ( + getattr(bundle_kb, "description", None) + if bundle_kb is not None + else None + ) items.append(( f"knowledge_base:{index_name}", display_name, - None, + description, )) return items @@ -254,6 +288,7 @@ def build_repository_import_precheck( display_name: str, snapshot: Any, tenant_id: str, + require_kb_embedding_model: bool = False, ) -> RepositoryImportPrecheckResponse: """Build import precheck response for a repository listing snapshot.""" tenant_tools = _build_tenant_tool_map(tenant_id) @@ -275,13 +310,29 @@ def build_repository_import_precheck( reason_code=reason, )) + # Only the official-bundle precheck opts into logical-name resolution. + # Ordinary repository snapshots must keep their existing index_name path. + official_snapshot = require_kb_embedding_model and bool( + getattr(snapshot, "knowledge_bases", None) + ) for key, kb_name, description in _extract_knowledge_bases(snapshot, tenant_id): index_name = key.split(":", 1)[1] - available, reason = _check_kb_available(index_name, tenant_id) - record = get_knowledge_record({ - "index_name": index_name, - "tenant_id": tenant_id, - }) + if official_snapshot: + # Official bundles do not contain a tenant index_name. They carry + # a logical reference and a user-facing knowledge_name instead. + record = get_knowledge_record({ + "knowledge_name": kb_name, + "tenant_id": tenant_id, + }) + available, reason = True, None + if record and require_kb_embedding_model: + available, reason = _check_kb_embedding_available(record, tenant_id) + else: + available, reason = _check_kb_available(index_name, tenant_id) + record = get_knowledge_record({ + "index_name": index_name, + "tenant_id": tenant_id, + }) kb_description = record.get("knowledge_describe") if record else description items.append(RepositoryImportRequirementItem( type="knowledge_base", @@ -290,6 +341,8 @@ def build_repository_import_precheck( description=kb_description, available=available, reason_code=reason, + resolution_required=bool(official_snapshot and record), + existing_index_name=record.get("index_name") if record else None, )) for server_name in sorted(_extract_mcp_server_names(snapshot)): @@ -328,6 +381,18 @@ def build_repository_import_precheck( source, tenant_tools, ) + if available: + try: + for agent in snapshot.agent_info.values(): + for tool in _agent_dict(agent).get("tools") or []: + data = _tool_dict(tool) + if data.get("class_name") == class_name and data.get("source") == source: + validate_import_tool_params( + class_name, source, data.get("params"), + tenant_tools.get(_tool_lookup_key(class_name, source)), + ) + except AgentToolImportError: + available, reason = False, "tool_params_incompatible" items.append(RepositoryImportRequirementItem( type="tool", key=key, diff --git a/backend/services/resource_tag_projection.py b/backend/services/resource_tag_projection.py new file mode 100644 index 0000000000..929e23548e --- /dev/null +++ b/backend/services/resource_tag_projection.py @@ -0,0 +1,32 @@ +"""Batch display-only tags after the resource service has applied authorization.""" + +from collections import defaultdict +from typing import Any + +from database.tag_management_db import TagManagementDB + + +def project_authorized_resource_tags( + resources: list[dict[str, Any]], *, resource_type: str, id_field: str, default_tenant_id: str, +) -> list[dict[str, Any]]: + """Never discover resources through tags, and never query assignments per card.""" + grouped_ids: dict[str, list[str]] = defaultdict(list) + for resource in resources: + if resource.get(id_field) is not None: + tenant = str(resource.get("tenant_id") or default_tenant_id) + grouped_ids[tenant].append(str(resource[id_field])) + by_tenant = { + tenant: TagManagementDB.list_resource_assignment_display_values_by_ids( + tenant, resource_type, list(dict.fromkeys(ids)), + ) + for tenant, ids in grouped_ids.items() + } + return [ + { + **resource, + "tags": list(dict.fromkeys( + by_tenant.get(str(resource.get("tenant_id") or default_tenant_id), {}).get(str(resource.get(id_field)), []) + )), + } + for resource in resources + ] diff --git a/backend/services/runtime_knowledge_mount.py b/backend/services/runtime_knowledge_mount.py new file mode 100644 index 0000000000..b89902145b --- /dev/null +++ b/backend/services/runtime_knowledge_mount.py @@ -0,0 +1,53 @@ +"""Request-local knowledge tool binding; never updates database records.""" +from copy import deepcopy +from math import isfinite + +from consts.const import ENABLE_AIDP_KNOWLEDGE +from consts.exceptions import ValidationError +from database.tool_db import query_all_tools + +MANAGED_CLASSES = {"KnowledgeBaseSearchTool", "AidpSearchTool"} +RESERVED = {"index_names", "kds_list", "display_names", "server_url", "api_key", "tenant_id", + "observer", "kds_name_to_id_map", "allowed_kds_set", "allowed_index_names"} + + +def mount_knowledge_records(records, scope, tenant_id): + """Return an isolated root tool list, preserving unrelated tools.""" + records = deepcopy(records) + if scope is None: + return records + source = "aidp" if ENABLE_AIDP_KNOWLEDGE else "local" + other = scope.local if source == "aidp" else scope.aidp + if other.mode == "override": + raise ValidationError("Knowledge source changed; reselect knowledge bases") + selection = getattr(scope, source) + kind = "AidpSearchTool" if source == "aidp" else "KnowledgeBaseSearchTool" + tool = next((t for t in records if t.get("class_name") == kind), None) + result = [t for t in records if t.get("class_name") not in MANAGED_CLASSES] + if selection.mode == "disabled" or (selection.mode == "inherit" and tool is None): + return result + if tool is None: + tool = next((deepcopy(t) for t in query_all_tools(tenant_id) + if t.get("class_name") == kind and t.get("is_available") is True), None) + if tool is None: + raise ValidationError("Managed knowledge tool is unavailable") + fields = {p["name"]: p for p in tool.get("params") or []} + for name, value in (scope.retrieval_config or {}).items(): + if name in RESERVED or name not in fields: + raise ValidationError(f"Unsupported knowledge parameter: {name}") + field_type = fields[name].get("type") + expected = {"string": str, "number": (int, float), "integer": int, + "boolean": bool, "array": list, "object": dict}.get(field_type) + if expected and (not isinstance(value, expected) or ( + field_type in {"number", "integer"} and (isinstance(value, bool) or not isfinite(value)) + )): + raise ValidationError(f"Invalid knowledge parameter: {name}") + choices = {"search_mode": {"hybrid", "accurate", "semantic"}, + "search_method": {"hybrid_search", "vector_search", "full_text_search"}, + "rerank_mode": {"performance", "high_accuracy"}, + "reranking_mode": {"performance", "high_accuracy"}} + if name in choices and value not in choices[name]: + raise ValidationError(f"Invalid knowledge parameter: {name}") + fields[name]["default"] = deepcopy(value) + tool["params"] = list(fields.values()) + return [*result, tool] diff --git a/backend/services/skill_repository_service.py b/backend/services/skill_repository_service.py index 3543d02ddd..830221654f 100644 --- a/backend/services/skill_repository_service.py +++ b/backend/services/skill_repository_service.py @@ -925,11 +925,17 @@ def _to_mine_skill_item( user_id: str, user_role: str, repository_by_skill_id: Dict[int, List[Dict[str, Any]]], + managed_tags_by_skill_id: Dict[str, List[str]], ) -> Dict[str, Any]: if skill.get("new_skill_padding"): return {"new_skill_padding": True} skill_id = skill.get("skill_id") + managed_tags = ( + managed_tags_by_skill_id.get(str(skill_id), []) + if skill_id is not None + else [] + ) repository_info = ( repository_by_skill_id.get(int(skill_id), []) if skill_id is not None @@ -940,7 +946,7 @@ def _to_mine_skill_item( "name": skill.get("name"), "description": skill.get("description"), "source": skill.get("source"), - "tags": _normalize_mine_skill_tags(skill.get("tags")), + "tags": managed_tags or _normalize_mine_skill_tags(skill.get("tags")), "group_ids": skill.get("group_ids") or [], "ingroup_permission": skill.get("ingroup_permission"), "created_by": skill.get("created_by"), @@ -1028,12 +1034,24 @@ def list_my_editable_skills_impl( paged_skills, tenant_id, ) + from database.tag_management_db import TagManagementDB + + managed_tags_by_skill_id = TagManagementDB.list_resource_assignment_display_values_by_ids( + tenant_id=tenant_id, + resource_type="skill", + resource_ids=[ + str(skill["skill_id"]) + for skill in paged_skills + if skill.get("skill_id") is not None + ], + ) items = [ _to_mine_skill_item( skill, user_id=user_id, user_role=user_role, repository_by_skill_id=repository_by_skill_id, + managed_tags_by_skill_id=managed_tags_by_skill_id, ) for skill in paged_skills ] diff --git a/backend/services/startup_recovery_service.py b/backend/services/startup_recovery_service.py index 539d02d17d..a7e4334494 100644 --- a/backend/services/startup_recovery_service.py +++ b/backend/services/startup_recovery_service.py @@ -10,6 +10,7 @@ UPLOAD_RECOVERY_GRACE_SECONDS = 30 * 60 _upload_cleanup_tasks: set[asyncio.Task] = set() +_system_agent_backfill_tasks: set[asyncio.Task] = set() def recover_runtime_tasks() -> dict[str, int]: @@ -186,3 +187,19 @@ async def _delayed_cleanup() -> None: task = asyncio.create_task(_delayed_cleanup()) _upload_cleanup_tasks.add(task) task.add_done_callback(_upload_cleanup_tasks.discard) + + +def schedule_workbench_main_backfill() -> None: + """Run historical tenant system-Agent bootstrap without blocking startup.""" + + async def _backfill() -> None: + from services.tenant_service import backfill_workbench_main_agents + + try: + await asyncio.to_thread(backfill_workbench_main_agents) + except Exception: + logger.exception("Workbench system Agent backfill failed") + + task = asyncio.create_task(_backfill()) + _system_agent_backfill_tasks.add(task) + task.add_done_callback(_system_agent_backfill_tasks.discard) diff --git a/backend/services/tag_management_service.py b/backend/services/tag_management_service.py index c48dcc1daa..2367d190ff 100644 --- a/backend/services/tag_management_service.py +++ b/backend/services/tag_management_service.py @@ -39,7 +39,10 @@ def _translate_database_error(error: Exception) -> None: "Tag value capacity exceeded", {"limit": 1000, "current_count": 1000, "scope": "value"}, ) - if "Resource tag assignment limit exceeded" in message: + # The DB trigger omits the "Resource " prefix; match both spellings. + if "Resource tag assignment limit exceeded" in message or ( + "Tag assignment limit exceeded" in message + ): raise TagManagementConflictError( "Resource tag assignment capacity exceeded", {"limit": 100, "current_count": 100, "scope": "assignment"}, diff --git a/backend/services/tenant_service.py b/backend/services/tenant_service.py index 661a0528e6..fd534c1a6c 100644 --- a/backend/services/tenant_service.py +++ b/backend/services/tenant_service.py @@ -35,9 +35,17 @@ TENANT_ID, TENANT_NAME, IS_SPEED_MODE, + ENABLE_AGENT_WORKBENCH, +) +from consts.exceptions import ( + ForbiddenError, + NotFoundException, + TenantResourceLimitError, + UserRegistrationException, + ValidationError, ) -from consts.exceptions import ForbiddenError, NotFoundException, ValidationError, UserRegistrationException from management.services.skill.service import install_skills_from_zip_for_tenant +from management.services.agent.system_agent_provider import ensure_workbench_main_agent logger = logging.getLogger(__name__) @@ -220,6 +228,48 @@ def get_tenants_paginated_for_user( return get_tenants_paginated(page=page, page_size=page_size) +def backfill_workbench_main_agents() -> Dict[str, int]: + """Ensure the tenant-scoped workbench Agent for every existing real tenant. + + The operation is deliberately reentrant. Each tenant is isolated so that a + malformed or temporarily unavailable tenant does not prevent the remaining + tenants from being upgraded. + """ + if not ENABLE_AGENT_WORKBENCH: + return {"total": 0, "succeeded": 0, "failed": 0} + + tenant_ids = [ + tenant_id + for tenant_id in get_all_tenant_ids() + if _is_displayable_tenant_id(tenant_id) + ] + succeeded = 0 + failed = 0 + + for tenant_id in tenant_ids: + try: + ensure_workbench_main_agent( + tenant_id=tenant_id, + user_id="system", + ) + succeeded += 1 + except Exception as exc: + failed += 1 + logger.warning( + "Failed to backfill workbench_main for tenant %s: %s", + tenant_id, + exc, + ) + + result = { + "total": len(tenant_ids), + "succeeded": succeeded, + "failed": failed, + } + logger.info("Workbench system Agent backfill completed: %s", result) + return result + + def create_tenant( tenant_name: str, created_by: Optional[str] = None, @@ -289,6 +339,22 @@ def create_tenant( logger.warning( f"Failed to install skills by IDs for tenant {tenant_id}: {e}") + if ENABLE_AGENT_WORKBENCH: + try: + ensure_workbench_main_agent( + tenant_id=tenant_id, + user_id=created_by or "system", + locale=locale, + ) + except Exception as e: + # Tenant creation remains recoverable because Workbench runtime also + # calls the provider lazily before using the system Agent. + logger.warning( + "Failed to provision workbench_main for tenant %s: %s", + tenant_id, + e, + ) + tenant_info = { "tenant_id": tenant_id, "tenant_name": tenant_name.strip(), @@ -300,6 +366,8 @@ def create_tenant( f"Created tenant {tenant_id} with name '{tenant_name}' and default group {default_group_id}") return tenant_info + except TenantResourceLimitError: + raise except Exception as e: logger.error(f"Failed to create tenant {tenant_id}: {str(e)}") raise ValidationError(f"Failed to create tenant: {str(e)}") @@ -511,10 +579,20 @@ async def delete_single_user(user: Dict[str, Any]) -> None: agent_id = agent.get("agent_id") # Delete tool instances first delete_tools_by_agent_id( - agent_id, tenant_id, deleted_by or "system", version_no=0) + agent_id, + tenant_id, + deleted_by or "system", + version_no=0, + allow_system=True, + ) # Delete agent relationships delete_agent_relationship( - agent_id, tenant_id, deleted_by or "system", version_no=0) + agent_id, + tenant_id, + deleted_by or "system", + version_no=0, + allow_system=True, + ) # Delete the agent delete_agent_by_id(agent_id, tenant_id, deleted_by or "system") except Exception as e: @@ -528,9 +606,19 @@ async def delete_single_user(user: Dict[str, Any]) -> None: try: agent_id = agent.get("agent_id") delete_tools_by_agent_id( - agent_id, tenant_id, deleted_by or "system", version_no=1) + agent_id, + tenant_id, + deleted_by or "system", + version_no=1, + allow_system=True, + ) delete_agent_relationship( - agent_id, tenant_id, deleted_by or "system", version_no=1) + agent_id, + tenant_id, + deleted_by or "system", + version_no=1, + allow_system=True, + ) delete_agent_by_id(agent_id, tenant_id, deleted_by or "system") except Exception as e: logger.warning( diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index 42ded495e4..38f0b12743 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -1,3 +1,5 @@ +import asyncio +from contextlib import AsyncExitStack import importlib import inspect import json @@ -20,7 +22,11 @@ ENABLE_AIDP_KNOWLEDGE, LOCAL_MCP_SERVER, MCP_MANAGEMENT_API, + MCP_REQUEST_TIMEOUT_SECONDS, + RUNTIME_MCP_TOOL_TIMEOUT_SECONDS, + TOKEN, ) +from utils.mcp_url_utils import get_tenant_local_mcp_server from consts.error_message import ErrorMessage from consts.exceptions import MCPConnectionError, NotFoundException, ToolExecutionException, ValidationError from consts.model import ToolInstanceInfoRequest, ToolInfo, ToolSourceEnum, ToolValidateRequest @@ -405,11 +411,11 @@ async def get_all_mcp_tools(tenant_id: str) -> List[ToolInfo]: except Exception as e: logger.error(f"mcp connection error: {str(e)}") - default_mcp_url = urljoin(LOCAL_MCP_SERVER, "sse") + default_mcp_url = get_tenant_local_mcp_server(tenant_id) tools_info.extend(await get_tool_from_remote_mcp_server( mcp_server_name="outer-apis", remote_mcp_server=default_mcp_url, - tenant_id=None + tenant_id=tenant_id )) return tools_info @@ -698,13 +704,23 @@ async def get_tool_from_remote_mcp_server( mcp_server=remote_mcp_server, tenant_id=tenant_id ) + if tenant_id and "/mcp/" in remote_mcp_server: + custom_headers = { + **(custom_headers or {}), + "X-Tenant-ID": str(tenant_id), + "X-Nexent-Internal-Token": TOKEN, + } tools_info = [] try: transport = _create_mcp_transport(remote_mcp_server, authorization_token, custom_headers) - client = Client(transport=transport, timeout=10) - async with client: + client = Client(transport=transport, timeout=RUNTIME_MCP_TOOL_TIMEOUT_SECONDS) + async with AsyncExitStack() as stack: + await asyncio.wait_for( + stack.enter_async_context(client), + timeout=MCP_REQUEST_TIMEOUT_SECONDS, + ) # List available operations tools = await client.list_tools() @@ -803,6 +819,15 @@ async def update_tool_list(tenant_id: str, user_id: str): for record in get_mcp_records_by_tenant(tenant_id=tenant_id) if bool(record.get("enabled")) } + # API-converted services are persisted separately from regular MCP records, + # but their scanned tools use the shared ``outer-apis`` usage value. Keep + # those tools available during a transient MCP scan failure as long as at + # least one API service still exists for the tenant. + try: + if query_openapi_services_by_tenant(tenant_id): + enabled_mcp_names.add("outer-apis") + except Exception as exc: + logger.warning("Failed to check API-converted services for tenant %s: %s", tenant_id, exc) update_tool_table_from_scan_tool_list(tenant_id=tenant_id, user_id=user_id, @@ -942,24 +967,34 @@ async def _call_mcp_tool( MCPConnectionError: If MCP connection fails """ transport = _create_mcp_transport(mcp_url, authorization_token, custom_headers) - client = Client(transport=transport) - async with client: + client = Client(transport=transport, timeout=RUNTIME_MCP_TOOL_TIMEOUT_SECONDS) + async with AsyncExitStack() as stack: + await asyncio.wait_for( + stack.enter_async_context(client), + timeout=MCP_REQUEST_TIMEOUT_SECONDS, + ) # Check if connected if not client.is_connected(): logger.error("Failed to connect to MCP server") raise MCPConnectionError("Failed to connect to MCP server") # Call the tool - result = await client.call_tool( - name=tool_name, - arguments=inputs - ) + try: + result = await asyncio.wait_for( + client.call_tool(name=tool_name, arguments=inputs), + timeout=RUNTIME_MCP_TOOL_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError as exc: + raise MCPConnectionError( + f"MCP tool execution timed out after {RUNTIME_MCP_TOOL_TIMEOUT_SECONDS:g} seconds" + ) from exc return result.content[0].text async def _validate_mcp_tool_nexent( tool_name: str, - inputs: Optional[Dict[str, Any]] + inputs: Optional[Dict[str, Any]], + tenant_id: Optional[str] = None, ) -> Dict[str, Any]: """ Validate MCP tool using local nexent server. @@ -974,7 +1009,21 @@ async def _validate_mcp_tool_nexent( Raises: MCPConnectionError: If MCP connection fails """ - actual_mcp_url = urljoin(LOCAL_MCP_SERVER, "sse") + actual_mcp_url = ( + get_tenant_local_mcp_server(tenant_id) + if tenant_id + else urljoin(LOCAL_MCP_SERVER, "sse") + ) + if tenant_id: + return await _call_mcp_tool( + actual_mcp_url, + tool_name, + inputs, + custom_headers={ + "X-Tenant-ID": str(tenant_id), + "X-Nexent-Internal-Token": TOKEN, + }, + ) return await _call_mcp_tool(actual_mcp_url, tool_name, inputs) @@ -1363,7 +1412,7 @@ async def validate_tool_impl( request.name, request.inputs, request.source, request.usage, request.params) if source == ToolSourceEnum.MCP.value: if usage == "outer-apis": - return await _validate_mcp_tool_nexent(tool_name, inputs) + return await _validate_mcp_tool_nexent(tool_name, inputs, tenant_id) else: return await _validate_mcp_tool_remote(tool_name, inputs, usage, tenant_id) elif source == ToolSourceEnum.LOCAL.value: diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index dab906496f..e5084ffc89 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -16,6 +16,7 @@ from utils.auth_utils import ( get_supabase_client, get_supabase_admin_client, + delete_supabase_user, calculate_expires_at, get_jwt_expiry_seconds, ensure_cas_session_active_from_authorization, @@ -40,6 +41,7 @@ IncorrectInviteCodeException, UserRegistrationException, UnauthorizedError, + TenantResourceLimitError, ValidationError, ) from consts.error_code import ErrorCode @@ -249,8 +251,14 @@ async def signup_user_with_invitation(email: EmailStr, is_asset_owner_registration = user_role == ASSET_OWNER_ROLE # Create user tenant relationship - insert_user_tenant( - user_id=user_id, tenant_id=tenant_id, user_role=user_role, user_email=email) + try: + insert_user_tenant( + user_id=user_id, tenant_id=tenant_id, user_role=user_role, user_email=email) + except TenantResourceLimitError: + # Supabase auth creation happens before the local tenant-limit check. + # Remove the auth identity so a rejected registration is fully rolled back. + delete_supabase_user(user_id) + raise # Use invitation code now that we have the real user_id if invitation_info: diff --git a/backend/services/workbench_creation_history_service.py b/backend/services/workbench_creation_history_service.py new file mode 100644 index 0000000000..017cebe8f8 --- /dev/null +++ b/backend/services/workbench_creation_history_service.py @@ -0,0 +1,207 @@ +"""Persist Workbench creation runs without changing the editor's ephemeral NL2 flows.""" + +import json +import logging +from collections.abc import AsyncIterator +from typing import Any + +from consts.model import MessageRequest, MessageUnit, WorkbenchSessionConfig +from database.agent_db import query_agent_records_for_nl2agent +from database.conversation_db import get_conversation_history, rebind_conversation_agent_id +from services.conversation_management_service import ( + create_new_conversation, + save_message, + save_message_unit, +) + +logger = logging.getLogger(__name__) +_CREATION_MODES = {"agent_create", "skill_create"} + + +def _file_object_names(files: Any) -> list[str]: + if not isinstance(files, list): + return [] + return [ + item if isinstance(item, str) else str(item.get("object_name") or "") + for item in files + ] + + +def prepare_creation_history( + *, + conversation_id: int | None, + mode: str, + query: str, + minio_files: list[dict[str, Any]] | None, + workbench_config: dict[str, Any] | None, + agent_id: int | None, + user_id: str, + tenant_id: str, + retry_user_message_id: int | None = None, + retry_message_index: int | None = None, +) -> tuple[int, int]: + """Verify the owner and creation mode, then persist the current user turn.""" + if mode not in _CREATION_MODES: + raise ValueError("Unsupported Workbench creation mode") + if conversation_id is None: + if retry_user_message_id is not None or retry_message_index is not None: + raise ValueError("Cannot retry without a creation conversation") + if not isinstance(workbench_config, dict): + raise ValueError("Invalid Workbench creation configuration") + normalized_config = WorkbenchSessionConfig.model_validate( + workbench_config + ).model_dump(mode="json") + if normalized_config["mode"] != mode: + raise ValueError("Invalid Workbench creation configuration") + created = create_new_conversation( + title="新对话", + user_id=user_id, + agent_id=agent_id if mode == "agent_create" else None, + workbench_config=normalized_config, + ) + resolved_id = int(created["conversation_id"]) + next_index = 0 + else: + history = get_conversation_history(conversation_id, user_id) + if not history or (history.get("workbench_config") or {}).get("mode") != mode: + raise ValueError("Conversation is not an accessible creation session") + if mode == "agent_create" and history.get("agent_id") != agent_id: + old_agent_id = history.get("agent_id") + old_records = ( + query_agent_records_for_nl2agent(old_agent_id, tenant_id) + if isinstance(old_agent_id, int) and old_agent_id > 0 + else [] + ) + # An inaccessible or still-live Agent must never be silently replaced. + # Soft-deleted tenant-owned records are the only recoverable case. + if ( + retry_user_message_id is not None + or retry_message_index is not None + or not isinstance(agent_id, int) + or agent_id <= 0 + or not old_records + or any(record.get("delete_flag") != "Y" for record in old_records) + or not rebind_conversation_agent_id( + conversation_id, old_agent_id, agent_id, user_id + ) + ): + raise ValueError("Agent does not match the creation session") + resolved_id = conversation_id + if retry_user_message_id is not None or retry_message_index is not None: + user_record = next( + ( + item for item in history["message_records"] + if item.get("role") == "user" + and ( + item.get("message_id") == retry_user_message_id + if retry_user_message_id is not None + else item.get("message_index") == retry_message_index + ) + ), + None, + ) + if ( + user_record is None + or (retry_message_index is not None and + user_record.get("message_index") != retry_message_index) + or user_record.get("message_content") != query + or _file_object_names(user_record.get("minio_files")) + != _file_object_names(minio_files) + ): + raise ValueError("Invalid creation retry target") + assistant_index = int(user_record["message_index"]) + 1 + if not any( + item.get("role") == "assistant" + and item.get("message_index") == assistant_index + for item in history["message_records"] + ): + raise ValueError("Creation retry target has no assistant response") + return resolved_id, assistant_index + next_index = max( + (int(item["message_index"]) for item in history["message_records"]), + default=-1, + ) + 1 + + save_message( + MessageRequest( + conversation_id=resolved_id, + message_idx=next_index, + role="user", + message=[MessageUnit(type="string", content=query)], + minio_files=minio_files, + ), + user_id=user_id, + tenant_id=tenant_id, + ) + return resolved_id, next_index + 1 + + +async def persist_creation_stream( + stream: AsyncIterator[str], + *, + conversation_id: int, + assistant_index: int, + user_id: str, + tenant_id: str, +) -> AsyncIterator[str]: + """Forward each SSE event unchanged and save a replayable assistant turn.""" + units: list[tuple[str, str]] = [] + status = "completed" + try: + async for event in stream: + for line in event.splitlines(): + if not line.startswith("data: "): + continue + try: + chunk = json.loads(line[6:]) + except json.JSONDecodeError: + continue + if not isinstance(chunk, dict) or not isinstance(chunk.get("type"), str): + continue + kind = chunk["type"] + raw_content = chunk.get("content") + content = ( + raw_content + if isinstance(raw_content, str) + else json.dumps(raw_content, ensure_ascii=False) + ) + if kind in { + "skill_body", "file_content", "agent_new_run", + "target_files", "model_attempt_control", "done", + }: + content = json.dumps(chunk, ensure_ascii=False) + units.append((kind, content)) + if kind == "error": + status = "failed" + yield event + except BaseException: + status = "stopped" + raise + finally: + try: + message = MessageRequest( + conversation_id=conversation_id, + message_idx=assistant_index, + role="assistant", + message=[ + MessageUnit(type=kind, content=content) + for kind, content in units + ], + ) + message_id = save_message( + message, + user_id=user_id, + tenant_id=tenant_id, + status=status, + ) + for index, (kind, content) in enumerate(units): + save_message_unit( + message_id=message_id, + conversation_id=conversation_id, + unit_index=index, + unit_type=kind, + unit_content=content, + user_id=user_id, + ) + except Exception: + logger.exception("Failed to persist Workbench creation transcript") diff --git a/backend/services/workbench_service.py b/backend/services/workbench_service.py new file mode 100644 index 0000000000..50baf42312 --- /dev/null +++ b/backend/services/workbench_service.py @@ -0,0 +1,587 @@ +"""Resolve Workbench declarations into immutable, request-scoped runtime overlays.""" + +from __future__ import annotations + +import shutil +import tempfile +from copy import deepcopy +from dataclasses import dataclass, replace +from math import isfinite +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Tuple +from nexent.core.agents.agent_model import AgentConfig + +from consts.exceptions import ValidationError, WorkbenchConfigVersionConflict, WorkbenchError +from consts.const import CAN_EDIT_ALL_USER_ROLES, PERMISSION_PRIVATE +from consts.model import WorkbenchSessionConfig +from database import skill_db +from database.tool_db import query_tools_by_ids +from database.agent_db import search_agent_info_by_agent_id +from database.model_management_db import get_model_by_model_id +from management.services.agent.system_agent_provider import system_agent_provider +from management.services.model.resolver import is_model_available +from management.services.skill.service import SkillService +from management.services.skill.support import can_view_skill, _get_user_role, _to_group_id_set +from database.group_db import query_group_ids_by_user +from utils.runtime_config_utils import clone_runtime_config +from services.knowledge_scope_service import ( + ResolvedKnowledgeScope, + get_agent_knowledge_capabilities, + resolve_root_version, +) + + +@dataclass(frozen=True) +class RuntimeAgentIdentity: + runtime_ref: str + agent_id: int | str | None + version_no: int | None + invocation_name: str + display_name: str + origin: str + + +@dataclass(frozen=True) +class PublishedRootDescriptor: + """Authorized immutable input used only by the persisted-root preparation step.""" + + identity: RuntimeAgentIdentity + published_snapshot: Mapping[str, Any] + + +@dataclass(frozen=True) +class RuntimeRootDescriptor: + """Executable root contract shared by persisted and external system roots.""" + + identity: RuntimeAgentIdentity + agent_config: AgentConfig + + +@dataclass(frozen=True) +class RootRuntimeOverlay: + model_id: Optional[int] + requested_output_tokens: Optional[int] + skill_mounts: Tuple[Mapping[str, Any], ...] + knowledge_scope: Optional[Mapping[str, Any]] + + +@dataclass(frozen=True) +class ResolvedSkillMount: + skill_id: int + name: str + description: str + tool_ids: Tuple[int, ...] + config_values: Mapping[str, Any] + tool_definitions: Tuple[Mapping[str, Any], ...] = () + files: Tuple[Tuple[str, bytes], ...] = () + + +@dataclass(frozen=True) +class ResolvedAgentPlan: + root: PublishedRootDescriptor + overlay: RootRuntimeOverlay + root_skills: Tuple[ResolvedSkillMount, ...] + child_mounts: Tuple[RuntimeAgentIdentity, ...] = () + knowledge_tree: Tuple[Mapping[str, Any], ...] = () + + +@dataclass(frozen=True) +class ResolvedAgentTree: + root: RuntimeRootDescriptor + overlay: RootRuntimeOverlay + root_skills: Tuple[ResolvedSkillMount, ...] + knowledge_tree: Tuple[Mapping[str, Any], ...] = () + + +class RuntimeAgentTreeComposer: + """Compose cloned child configs into a request-scoped root.""" + + def compose( + self, + root: AgentConfig, + children: list[AgentConfig] | tuple[AgentConfig, ...], + ) -> AgentConfig: + composed = clone_runtime_config(root) + composed.managed_agents = [clone_runtime_config(child) for child in children] + return composed + + +_MAX_SKILL_SNAPSHOT_FILES = 256 +_MAX_SKILL_SNAPSHOT_BYTES = 16 * 1024 * 1024 + + +def _capture_skill_file_snapshot( + skill_name: str, + tenant_id: str, +) -> Tuple[Tuple[str, bytes], ...]: + """Capture a bounded immutable file set and remove the temporary source copy.""" + loaded = SkillService(tenant_id=tenant_id).load_skill_directory(skill_name) + directory = loaded.get("directory") if isinstance(loaded, dict) else None + if not directory: + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + root = Path(directory).resolve() + temp_root = Path(tempfile.gettempdir()).resolve() + if ( + not root.is_relative_to(temp_root) + or not root.name.startswith(f"skill_{skill_name}_") + ): + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + files: list[Tuple[str, bytes]] = [] + total_bytes = 0 + try: + for path in sorted(root.rglob("*")): + if not path.is_file() or path.is_symlink(): + continue + relative = path.resolve().relative_to(root).as_posix() + content = path.read_bytes() + total_bytes += len(content) + if ( + len(files) >= _MAX_SKILL_SNAPSHOT_FILES + or total_bytes > _MAX_SKILL_SNAPSHOT_BYTES + ): + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + files.append((relative, content)) + finally: + shutil.rmtree(root, ignore_errors=True) + if not any(path == "SKILL.md" for path, _content in files): + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + return tuple(files) + + +def _resolve_skill_mounts( + mounts: Tuple[Mapping[str, Any], ...], + tenant_id: str, + published_instances: Tuple[Mapping[str, Any], ...] = (), + user_id: Optional[str] = None, +) -> Tuple[ResolvedSkillMount, ...]: + if not mounts and not published_instances: + return () + runtime_mounts = {int(mount["skill_id"]): mount for mount in mounts} + effective_mounts: list[Mapping[str, Any]] = [] + seen_skill_ids: set[int] = set() + for instance in published_instances: + skill_id = int(instance["skill_id"]) + if skill_id in seen_skill_ids: + continue + seen_skill_ids.add(skill_id) + effective_mounts.append( + runtime_mounts.pop( + skill_id, + {"skill_id": skill_id, "config_values": {}}, + ) + ) + effective_mounts.extend(runtime_mounts.values()) + + resolved = [] + role = _get_user_role(user_id) if user_id else None + groups = set(query_group_ids_by_user(user_id) or []) if user_id else set() + for mount in effective_mounts: + skill_id = int(mount["skill_id"]) + skill = skill_db.get_skill_by_id(skill_id, tenant_id) + if skill is None: + raise WorkbenchError("RUNTIME_SKILL_FORBIDDEN", status_code=403) + if user_id and not can_view_skill(skill=skill, user_id=user_id, user_role=role, user_group_ids=groups): + raise WorkbenchError("RUNTIME_SKILL_FORBIDDEN", status_code=403) + instance = next((item for item in published_instances if item.get("skill_id") == skill_id), {}) + config_values = resolve_skill_config( + skill.get("config_schemas") or [], + skill.get("config_values") or {}, + instance.get("config_values") or {}, + mount.get("config_values") or {}, + ) + resolved.append( + ResolvedSkillMount( + skill_id=skill_id, + name=str(skill.get("name") or skill_id), + description=str(skill.get("description") or ""), + tool_ids=tuple(int(value) for value in (skill.get("tool_ids") or [])), + config_values=config_values, + files=_capture_skill_file_snapshot( + str(skill.get("name") or skill_id), + tenant_id, + ), + ) + ) + tool_ids = sorted({tool_id for skill in resolved for tool_id in skill.tool_ids}) + definitions = {int(tool["tool_id"]): deepcopy(tool) for tool in query_tools_by_ids(tool_ids)} if tool_ids else {} + for tool_id in tool_ids: + tool = definitions.get(tool_id) + if not tool or tool.get("is_available") is not True or str(tool.get("author")) != tenant_id: + raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE") + values: Dict[str, Any] = {} + parameter_names = {param.get("name") for param in tool.get("params") or []} + for skill in resolved: + if tool_id not in skill.tool_ids: + continue + for name, value in skill.config_values.items(): + if name not in parameter_names: + continue + if name in values and values[name] != value: + raise WorkbenchError("RUNTIME_SKILL_TOOL_CONFLICT") + values[name] = value + return tuple(replace(skill, tool_definitions=tuple(deepcopy(definitions[tool_id]) for tool_id in skill.tool_ids)) for skill in resolved) + + +def build_workbench_main_profile(tenant_id: str) -> Dict[str, Any]: + """Return public presentation metadata for the tenant system root.""" + ref = system_agent_provider.get_workbench_main_ref(tenant_id) + skills = SkillService(tenant_id=tenant_id).get_enabled_skills_for_agent( + agent_id=ref.agent_id, + tenant_id=tenant_id, + version_no=ref.version_no, + ) + return { + "agent_id": ref.agent_id, + "version_no": ref.version_no, + "display_name": "Nexent Workbench", + "default_skill_resources": [ + { + "skill_id": int(skill["skill_id"]), + "name": str(skill.get("name") or skill["skill_id"]), + "description": str(skill.get("description") or ""), + } + for skill in skills + ], + } + + +def resolve_skill_config(schemas, defaults, published, runtime): + """Validate declared configuration after applying the three precedence layers.""" + fields = {item["name"]: item for item in schemas if isinstance(item, dict) and item.get("name")} + values = deepcopy(defaults) + values.update(deepcopy(published)) + values.update(deepcopy(runtime)) + if any(name not in fields for name in runtime): + raise WorkbenchError("RUNTIME_SKILL_CONFIG_INVALID") + types = { + "string": str, "boolean": bool, "integer": int, + "number": (int, float), "object": dict, "array": list, + } + for name, schema in fields.items(): + value = values.get(name) + if value is None and "default" in schema: + value = values[name] = deepcopy(schema["default"]) + if value is None or value == "": + if schema.get("required"): + raise WorkbenchError("RUNTIME_SKILL_CONFIG_INVALID") + continue + expected = types.get(schema.get("type")) + if expected and (not isinstance(value, expected) or ( + schema.get("type") in {"integer", "number"} and isinstance(value, bool) + )): + raise WorkbenchError("RUNTIME_SKILL_CONFIG_INVALID") + if isinstance(schema.get("enum"), list) and value not in schema["enum"]: + raise WorkbenchError("RUNTIME_SKILL_CONFIG_INVALID") + if schema.get("type") in {"integer", "number"}: + if not isfinite(value): + raise WorkbenchError("RUNTIME_SKILL_CONFIG_INVALID") + if schema.get("minimum") is not None and value < schema["minimum"]: + raise WorkbenchError("RUNTIME_SKILL_CONFIG_INVALID") + if schema.get("maximum") is not None and value > schema["maximum"]: + raise WorkbenchError("RUNTIME_SKILL_CONFIG_INVALID") + return values + + +class RuntimeMountService: + """Compile an already-authorized executable root without repository access.""" + + def resolve_knowledge( + self, + tree: ResolvedAgentTree, + *, + tenant_id: str, + user_id: str, + ) -> tuple[ResolvedAgentTree, ResolvedKnowledgeScope]: + """Resolve resource access for an executable root without loading Agent records. + + Callers consume the returned tree and scope summary before starting a + model. Resource checks are separate from the pure root compilation. + """ + from consts.model import ConversationKnowledgeScopeRequest + from services.knowledge_scope_service import resolve_executable_knowledge_scope + + scope = ConversationKnowledgeScopeRequest.model_validate(dict(tree.overlay.knowledge_scope or {})) + compiled, resolution = resolve_executable_knowledge_scope( + tree.root.agent_config, scope, tenant_id=tenant_id, user_id=user_id, + ) + return replace(tree, root=replace(tree.root, agent_config=compiled)), resolution + + def resolve_root( + self, + root: RuntimeRootDescriptor, + overlay: RootRuntimeOverlay, + *, + resolved_skills: Tuple[ResolvedSkillMount, ...] = (), + knowledge_tree: Tuple[Mapping[str, Any], ...] = (), + ) -> ResolvedAgentTree: + agent_config = clone_runtime_config(root.agent_config) + identity = root.identity + agent_config.agent_id = identity.agent_id + agent_config.version_no = identity.version_no + agent_config.invocation_name = identity.invocation_name + agent_config.runtime_ref = identity.runtime_ref + agent_config.display_name = identity.display_name + agent_config.origin = identity.origin + return ResolvedAgentTree( + root=RuntimeRootDescriptor( + identity=identity, + agent_config=agent_config, + ), + overlay=RootRuntimeOverlay( + model_id=overlay.model_id, + requested_output_tokens=overlay.requested_output_tokens, + skill_mounts=tuple(deepcopy(dict(item)) for item in overlay.skill_mounts), + knowledge_scope=( + deepcopy(dict(overlay.knowledge_scope)) + if overlay.knowledge_scope is not None + else None + ), + ), + root_skills=tuple(deepcopy(resolved_skills)), + knowledge_tree=tuple(deepcopy(knowledge_tree)), + ) + + +def compile_runtime_mount_plan( + plan: ResolvedAgentPlan, + agent_config: AgentConfig, +) -> ResolvedAgentTree: + """Compile one request-local executable tree from the authorized plan.""" + return RuntimeMountService().resolve_root( + RuntimeRootDescriptor(identity=plan.root.identity, agent_config=agent_config), + plan.overlay, + resolved_skills=plan.root_skills, + knowledge_tree=plan.knowledge_tree, + ) + + +def attach_runtime_knowledge_tree( + plan: ResolvedAgentPlan, + knowledge_tree: list[Mapping[str, Any]], +) -> ResolvedAgentPlan: + """Return a plan carrying the immutable knowledge projection for this run.""" + return replace( + plan, + knowledge_tree=tuple(deepcopy(dict(node)) for node in knowledge_tree), + ) + + +def resolve_workbench_config( + config: WorkbenchSessionConfig, + *, + tenant_id: str, + is_debug: bool = False, + user_id: Optional[str] = None, +) -> tuple[WorkbenchSessionConfig, ResolvedAgentPlan]: + """Lock the root version and compile the canonical Workbench declaration.""" + + if config.mode in {"skill_create", "agent_create"}: + raise WorkbenchError("WORKBENCH_MODE_RESOURCE_CONFLICT") + canonical_payload = config.model_dump(mode="json") + child_identities: list[RuntimeAgentIdentity] = [] + for index, mount in enumerate(config.agent_mounts): + resolved_version = resolve_root_version( + mount.agent_id, + tenant_id, + mount.version_no, + is_debug, + ) + if not is_debug and resolved_version <= 0: + raise WorkbenchError("AGENT_VERSION_UNAVAILABLE") + try: + selected_snapshot = deepcopy( + search_agent_info_by_agent_id( + mount.agent_id, tenant_id, resolved_version + ) + ) + except ValueError as exc: + raise WorkbenchError("AGENT_VERSION_UNAVAILABLE") from exc + if user_id: + authorize_workbench_agent(selected_snapshot, user_id) + if selected_snapshot.get("enabled") is False: + raise WorkbenchError("AGENT_NOT_RUNNABLE") + if selected_snapshot.get("system_key") is not None: + raise WorkbenchError("AGENT_NOT_RUNNABLE", status_code=403) + canonical_payload["agent_mounts"][index]["version_no"] = resolved_version + child_identities.append( + RuntimeAgentIdentity( + runtime_ref=f"agent:{mount.agent_id}:v{resolved_version}", + agent_id=mount.agent_id, + version_no=resolved_version, + invocation_name=f"agent_{mount.agent_id}_v{resolved_version}", + display_name=str( + selected_snapshot.get("display_name") + or selected_snapshot.get("name") + or mount.agent_id + ), + origin="PERSISTED", + ) + ) + + canonical = WorkbenchSessionConfig.model_validate(canonical_payload) + try: + system_ref = system_agent_provider.get_workbench_main_ref(tenant_id) + snapshot = deepcopy( + search_agent_info_by_agent_id( + system_ref.agent_id, + tenant_id, + system_ref.version_no, + ) + ) + except Exception as exc: + raise WorkbenchError("WORKBENCH_SYSTEM_AGENT_UNAVAILABLE", status_code=503) from exc + if snapshot.get("enabled") is False: + raise WorkbenchError("WORKBENCH_SYSTEM_AGENT_UNAVAILABLE", status_code=503) + root_identity = RuntimeAgentIdentity( + runtime_ref=f"agent:{system_ref.agent_id}:v{system_ref.version_no}", + agent_id=system_ref.agent_id, + version_no=system_ref.version_no, + invocation_name="workbench_main", + display_name=str( + snapshot.get("display_name") + or snapshot.get("name") + or "Nexent Workbench" + ), + origin="SYSTEM", + ) + child_mounts = tuple(child_identities) + if canonical.model_id is not None: + model_info = get_model_by_model_id( + canonical.model_id, tenant_id=tenant_id + ) + if not is_model_available(model_info): + raise WorkbenchError("WORKBENCH_MODEL_NOT_ALLOWED") + + snapshot["skill_instances"] = skill_db.search_skills_for_agent( + agent_id=int(root_identity.agent_id), + tenant_id=tenant_id, + version_no=int(root_identity.version_no), + ) + generation = canonical.generation_config + root = PublishedRootDescriptor( + identity=root_identity, + published_snapshot=deepcopy(snapshot), + ) + overlay = RootRuntimeOverlay( + model_id=canonical.model_id, + requested_output_tokens=generation.requested_output_tokens, + skill_mounts=tuple( + value.model_dump(mode="json") for value in canonical.skill_mounts + ), + knowledge_scope=( + canonical.knowledge_scope.model_dump(mode="json") + if canonical.knowledge_scope is not None + else None + ), + ) + return canonical, ResolvedAgentPlan( + root=root, + overlay=overlay, + root_skills=_resolve_skill_mounts( + overlay.skill_mounts, tenant_id, + tuple(root.published_snapshot.get("skill_instances") or []), + user_id=user_id, + ), + child_mounts=child_mounts, + ) + + +def authorize_workbench_agent(snapshot: Mapping[str, Any], user_id: str) -> None: + """Apply the Agent list's creator/group visibility rules before resolving resources.""" + role = _get_user_role(user_id) + if role in CAN_EDIT_ALL_USER_ROLES or str(snapshot.get("created_by")) == str(user_id): + return + groups = set(query_group_ids_by_user(user_id) or []) + if snapshot.get("ingroup_permission") == PERMISSION_PRIVATE or not groups.intersection(_to_group_id_set(snapshot.get("group_ids"))): + raise WorkbenchError("AGENT_NOT_RUNNABLE", status_code=403) + + +def build_workbench_capability_preview( + *, + agent_id: int, + version_no: Optional[int], + tenant_id: str, + user_id: str, +) -> Dict[str, Any]: + locked_version = resolve_root_version(agent_id, tenant_id, version_no, False) + if locked_version <= 0: + raise WorkbenchError("AGENT_VERSION_UNAVAILABLE") + try: + snapshot = search_agent_info_by_agent_id(agent_id, tenant_id, locked_version) + except ValueError as exc: + raise WorkbenchError("AGENT_VERSION_UNAVAILABLE") from exc + authorize_workbench_agent(snapshot, user_id) + if snapshot.get("enabled") is False: + raise WorkbenchError("AGENT_NOT_RUNNABLE") + capabilities = get_agent_knowledge_capabilities( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=locked_version, + user_id=user_id, + ) + locked_version = int(capabilities["version_no"]) + if locked_version <= 0: + raise ValidationError("The selected Agent has no published version") + default_skills = SkillService(tenant_id=tenant_id).get_enabled_skills_for_agent( + agent_id=agent_id, + tenant_id=tenant_id, + version_no=locked_version, + ) + role = _get_user_role(user_id) + groups = set(query_group_ids_by_user(user_id) or []) + for skill in default_skills: + authorized_skill = skill_db.get_skill_by_id(int(skill["skill_id"]), tenant_id) + if not authorized_skill or not can_view_skill( + skill=authorized_skill, + user_id=user_id, + user_role=role, + user_group_ids=groups, + ): + raise WorkbenchError("RUNTIME_SKILL_FORBIDDEN", status_code=403) + return { + "agent_id": int(agent_id), + "version_no": locked_version, + "default_skill_mounts": [ + { + "skill_id": int(skill["skill_id"]), + "config_values": deepcopy(skill.get("config_values") or {}), + } + for skill in default_skills + ], + "default_skill_resources": [ + { + "skill_id": int(skill["skill_id"]), + "name": str(skill.get("name") or skill["skill_id"]), + "description": str(skill.get("description") or ""), + } + for skill in default_skills + ], + "knowledge": capabilities, + } + + +def assert_workbench_version( + conversation: Mapping[str, Any], + expected_version: Optional[int], +) -> None: + current_version = int(conversation.get("workbench_config_version") or 0) + if expected_version is None or int(expected_version) != current_version: + raise WorkbenchConfigVersionConflict( + current_version, + deepcopy(conversation.get("workbench_config")), + ) + + +def runtime_skill_snapshot(tree: ResolvedAgentPlan | ResolvedAgentTree) -> list[Dict[str, Any]]: + return [ + { + "skill_id": skill.skill_id, + "name": skill.name, + "description": skill.description, + "tool_ids": list(skill.tool_ids), + "config_values": deepcopy(dict(skill.config_values)), + "tool_definitions": [deepcopy(dict(tool)) for tool in skill.tool_definitions], + "files": [(path, bytes(content)) for path, content in skill.files], + } + for skill in tree.root_skills + ] diff --git a/backend/tool_collection/mcp/nl2agent_mcp_tools.py b/backend/tool_collection/mcp/nl2agent_mcp_tools.py index 97e93a4c3c..a2bb64a9ef 100644 --- a/backend/tool_collection/mcp/nl2agent_mcp_tools.py +++ b/backend/tool_collection/mcp/nl2agent_mcp_tools.py @@ -96,7 +96,8 @@ "Partially update the current tenant's existing ordinary agent draft. " "Always pass the current agent_id and only whitelisted fields, never null. " "The name field may be set only when the existing draft name is empty; " - "display_name is immutable. Call the tool as " + "display_name may be set only alongside the first name on an empty Workbench Draft; " + "all other display names are immutable. Call the tool as " f"`result = {SAVE_AGENT_DRAFT_FIELDS_NAME}(...)`, then use `print(result)` exactly once." ) NL2AGENT_MCP_TOOL_META = {"nexent_internal": True} @@ -113,7 +114,7 @@ ) _NL2AGENT_FINAL_PROMPT_BATCH = frozenset({"greeting_message", "example_questions"}) _NL2AGENT_DRAFT_SYNC_FIELDS = frozenset( - {"name", "description", *_NL2AGENT_PROMPT_FIELDS} + {"name", "display_name", "description", *_NL2AGENT_PROMPT_FIELDS} ) NL2A_SUBTYPES = Literal[ "requirement_clarification", @@ -375,6 +376,7 @@ class AgentDraftFields(BaseModel): max_length=30, pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*_assistant$", ) + display_name: str | None = Field(default=None, min_length=1, max_length=100) description: str | None = None duty_prompt: str | None = None constraint_prompt: str | None = None @@ -447,6 +449,7 @@ class SaveAgentDraftFieldsError(BaseModel): "draft_save_failed", "agent_name_already_set", "agent_name_duplicate", + "agent_display_name_immutable", "draft_fields_incomplete", "prompt_fields_incomplete", "unauthorized", diff --git a/backend/utils/agent_stream_utils.py b/backend/utils/agent_stream_utils.py index 1f8cd0ffd4..09703db5ab 100644 --- a/backend/utils/agent_stream_utils.py +++ b/backend/utils/agent_stream_utils.py @@ -3,9 +3,20 @@ import json import logging import os -from typing import Any, Dict +from typing import Any, Dict, Optional from consts.agent import SAFE_AGENT_STREAM_ERROR_MESSAGE + +try: + from consts.agent import ( + REASONING_CONFIGURATION_ERROR_CODE, + SAFE_REASONING_CONFIGURATION_ERROR_MESSAGE, + ) +except ImportError: # Compatibility with slim test/runtime consts stubs. + from backend.consts.agent import ( + REASONING_CONFIGURATION_ERROR_CODE, + SAFE_REASONING_CONFIGURATION_ERROR_MESSAGE, + ) from database.attachment_db import _build_mcp_presigned_url, get_file_url, upload_fileobj from services.file_management_service import is_allowed_skill_upload_path @@ -238,10 +249,40 @@ async def process_skill_file_uploads( return upload_results -def safe_agent_stream_error_chunk() -> str: - """Return a sanitized SSE error chunk without internal exception details.""" +def _is_reasoning_configuration_error(exception: Optional[BaseException]) -> bool: + """Return whether an exception chain contains a reasoning configuration error.""" + current = exception + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if getattr(current, "is_reasoning_configuration_error", False): + return True + current = current.__cause__ or current.__context__ + return False + + +def _reasoning_configuration_error_chunk() -> str: + error_payload = json.dumps( + { + "type": "error", + "code": REASONING_CONFIGURATION_ERROR_CODE, + "content": SAFE_REASONING_CONFIGURATION_ERROR_MESSAGE, + }, + ensure_ascii=False, + ) + return f"data: {error_payload}\n\n" + + +def _generic_agent_stream_error_chunk() -> str: error_payload = json.dumps( {"type": "error", "content": SAFE_AGENT_STREAM_ERROR_MESSAGE}, ensure_ascii=False, ) return f"data: {error_payload}\n\n" + + +def safe_agent_stream_error_chunk(exception: Optional[BaseException] = None) -> str: + """Return a sanitized SSE error chunk without internal exception details.""" + if _is_reasoning_configuration_error(exception): + return _reasoning_configuration_error_chunk() + return _generic_agent_stream_error_chunk() diff --git a/backend/utils/agent_transfer_utils.py b/backend/utils/agent_transfer_utils.py new file mode 100644 index 0000000000..4a3a49dbe2 --- /dev/null +++ b/backend/utils/agent_transfer_utils.py @@ -0,0 +1,36 @@ +"""Keep runtime-owned AIDP connection state out of portable agent settings.""" + +from copy import deepcopy + + +class AgentToolImportError(ValueError): + """An exported tool is incompatible with the destination catalog.""" + + +AIDP_RUNTIME_PARAMS = frozenset({ + "api_key", "server_url", "tenant_id", "observer", + "allowed_kds_set", "kds_name_to_id_map", +}) + + +def portable_tool_params(class_name, params): + """Copy parameters, dropping only platform-managed AIDP runtime fields.""" + return { + name: deepcopy(value) + for name, value in (params or {}).items() + if class_name != "AidpSearchTool" or name not in AIDP_RUNTIME_PARAMS + } + + +def validate_import_tool_params(class_name, source, params, catalog_tool): + """Validate portable parameters without modifying the repository snapshot.""" + if catalog_tool is None: + raise AgentToolImportError(f"Cannot find tool {class_name} in {source}.") + portable = portable_tool_params(class_name, params) + allowed = {param["name"] for param in catalog_tool.get("params", [])} + for name in portable: + if name not in allowed: + raise AgentToolImportError( + f"Parameter {name} in tool {class_name} from {source} cannot be found." + ) + return portable diff --git a/backend/utils/auth_utils.py b/backend/utils/auth_utils.py index ca7fe67101..715bce2bde 100644 --- a/backend/utils/auth_utils.py +++ b/backend/utils/auth_utils.py @@ -306,6 +306,24 @@ def get_supabase_admin_client(): return None +def delete_supabase_user(user_id: str) -> bool: + """Delete an auth user during rollback of a failed local registration.""" + if not user_id: + return False + + try: + admin_client = get_supabase_admin_client() + if not admin_client or not hasattr(admin_client.auth, "admin"): + logger.warning("Supabase admin client unavailable while rolling back user %s", user_id) + return False + admin_client.auth.admin.delete_user(user_id) + logger.info("Rolled back Supabase user %s after failed registration", user_id) + return True + except Exception as exc: + logger.error("Failed to roll back Supabase user %s: %s", user_id, exc) + return False + + def get_jwt_expiry_seconds(token: str) -> int: """ Get expiration time from JWT token (seconds) diff --git a/backend/utils/config_utils.py b/backend/utils/config_utils.py index 6bcd75ff30..e7edb15003 100644 --- a/backend/utils/config_utils.py +++ b/backend/utils/config_utils.py @@ -48,8 +48,9 @@ def get_model_name_from_config(model_config: Dict[str, Any]) -> str: """Get model name from model id""" if model_config is None: return "" - model_repo = model_config["model_repo"] - model_name = model_config["model_name"] + # Quick-config entries may omit model_repo; treat it as an empty repo. + model_repo = model_config.get("model_repo") or "" + model_name = model_config.get("model_name") or model_config.get("modelName") or "" if not model_repo: return model_name return f"{model_repo}/{model_name}" diff --git a/backend/utils/config_validation.py b/backend/utils/config_validation.py new file mode 100644 index 0000000000..dcd559bc87 --- /dev/null +++ b/backend/utils/config_validation.py @@ -0,0 +1,27 @@ +"""Validation helpers for deployment configuration values.""" + +import math + + +def parse_positive_int(raw_value: object, name: str, default: int) -> int: + """Parse a positive integer, falling back to ``default`` when unset.""" + value = default if raw_value is None else raw_value + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a positive integer") from exc + if parsed <= 0: + raise ValueError(f"{name} must be a positive integer") + return parsed + + +def parse_positive_float(raw_value: object, name: str, default: float) -> float: + """Parse a finite positive float, falling back to ``default`` when unset.""" + value = default if raw_value is None else raw_value + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a positive number") from exc + if not math.isfinite(parsed) or parsed <= 0: + raise ValueError(f"{name} must be a positive number") + return parsed diff --git a/backend/utils/content_classifier_utils.py b/backend/utils/content_classifier_utils.py index 92d0a0a9de..5cf7f2c87c 100644 --- a/backend/utils/content_classifier_utils.py +++ b/backend/utils/content_classifier_utils.py @@ -4,6 +4,10 @@ from typing import Any, Dict, List, Optional +FINAL_ANSWER_OPEN_TAG = "" +FINAL_ANSWER_CLOSE_TAG = "" + + class ContentClassifier: """Parse XML tags from LLM output and classify streaming content in real-time. @@ -31,8 +35,9 @@ def __init__(self): self._origin_type: Optional[str] = None self._state_before_file = "others" self._known_tags = { - "", - "", + FINAL_ANSWER_OPEN_TAG, + FINAL_ANSWER_CLOSE_TAG, + "", "", "", @@ -108,7 +113,8 @@ def _process_tag_start(self, final: bool = False) -> Optional[List[Dict[str, Any if ( content_after_tag and not content_after_tag.startswith(("\n", "\r\n")) - and matched not in {"", ""} + and matched not in {FINAL_ANSWER_OPEN_TAG, FINAL_ANSWER_CLOSE_TAG} + ): return self._emit_potential_tag_start() results.extend(self._handle_matched_tag(gt_pos, potential_tag, matched)) @@ -264,7 +270,8 @@ def _create_event(self, content: str) -> Dict[str, Any]: def _handle_tag(self, tag: str) -> Optional[Dict[str, Any]]: """Handle matched tag and update state.""" - if tag in {"", ""}: + if tag in {FINAL_ANSWER_OPEN_TAG, FINAL_ANSWER_CLOSE_TAG}: + self.saw_control_tag = True return None diff --git a/backend/utils/logging_utils.py b/backend/utils/logging_utils.py index 7d58aedb51..135db52710 100644 --- a/backend/utils/logging_utils.py +++ b/backend/utils/logging_utils.py @@ -77,6 +77,31 @@ def doRollover(self): super().doRollover() +# Dedicated category for model runtime logs (nexent_model_call.log). +MODEL_CALL_CATEGORY = "model_call" + +# Logger names of the SDK model layer routed to the model_call file. Names are +# kept exactly as defined in the SDK (no rename); routing binds these loggers +# to the model_call file handler directly via the logconfig "loggers" section +# (dictConfig) or explicit handler binding (configure_logging), with +# propagate=False so the records never reach the per-service category files. +# Levels are left untouched (NOTSET) so they follow the root LOG_LEVEL; the +# model-body records in the SDK are logged at INFO for that reason. +MODEL_CALL_LOGGERS = ( + "openai_llm", + "openai_long_context_model", + "nexent.core.models.openai_vlm", + "nexent.core.models.ali_stt_model", + "nexent.core.models.ali_tts_model", + "volc_stt_model", + "volc_tts_model", + # Namespace for model-scoped loggers added on top (e.g. model_call.core_agent). + "model_call", + # Run-level "Agent loop context evidence" record (sdk/core/agents/context/evidence.py). + "context_evidence", +) + + def _make_file_handler(category: str) -> logging.Handler: """Create a hybrid time+size rotating file handler for a given category. @@ -115,6 +140,36 @@ def _make_console_handler() -> logging.Handler: return handler +def _bind_model_call_loggers(console_handler: logging.Handler, model_file_handler: logging.Handler): + """Bind model-layer loggers to the model_call file handler. + + Every whitelisted logger receives the console + model_call file handlers + and stops propagating, so its records never reach the root handlers (they + would otherwise be written into the service category file as well). The + console instance is shared with root, keeping docker logs behaviour + unchanged (model records still appear on stdout, exactly once). Levels are + left untouched so the whitelisted loggers follow the root LOG_LEVEL. + """ + for name in MODEL_CALL_LOGGERS: + named_logger = logging.getLogger(name) + named_logger.handlers.clear() + named_logger.addHandler(console_handler) + named_logger.addHandler(model_file_handler) + named_logger.propagate = False + + +def _unbind_model_call_loggers(): + """Undo _bind_model_call_loggers (used when model_call is not configured).""" + for name in MODEL_CALL_LOGGERS: + named_logger = logging.getLogger(name) + for handler in list(named_logger.handlers): + named_logger.removeHandler(handler) + named_logger.propagate = True + # Defensive reset: restore root-level inheritance even if the level was + # touched elsewhere while the loggers were bound. + named_logger.setLevel(logging.NOTSET) + + def configure_logging(level: int | None = None, categories: list[str] | None = None): """Configure root logger with console + file handlers. @@ -133,12 +188,26 @@ def configure_logging(level: int | None = None, categories: list[str] | None = N root_logger = logging.getLogger() root_logger.handlers.clear() - # Console handler (always present) - root_logger.addHandler(_make_console_handler()) + # One console instance shared between root and the model_call loggers so + # every record is printed exactly once. + console_handler = _make_console_handler() + root_logger.addHandler(console_handler) - # File handler per category + # The model_call file handler is bound ONLY to the whitelisted loggers + # below — never to root, otherwise every non-model record flowing through + # root would leak into the model file. + model_file_handler = None for cat in categories: - root_logger.addHandler(_make_file_handler(cat)) + handler = _make_file_handler(cat) + if cat == MODEL_CALL_CATEGORY: + model_file_handler = handler + else: + root_logger.addHandler(handler) + + if model_file_handler is not None: + _bind_model_call_loggers(console_handler, model_file_handler) + else: + _unbind_model_call_loggers() root_logger.setLevel(level) @@ -197,15 +266,31 @@ def get_uvicorn_logging_config(categories: list[str] | None = None) -> dict: }, } - # --- Root logger: console + all file handlers --- - handler_names = ["console"] + [f"file_{cat}" for cat in categories] + # --- Root logger: console + all file handlers except model_call --- + # file_model_call is instantiated below but bound only to the whitelisted + # loggers in the "loggers" section — never to root — so non-model records + # cannot leak into the model file. + root_handler_names = ["console"] + [ + f"file_{cat}" for cat in categories if cat != MODEL_CALL_CATEGORY + ] config: dict[str, object] = { "version": 1, "disable_existing_loggers": False, "formatters": formatters, "handlers": {**{"console": console_handler}, **file_handlers}, - "root": {"level": level, "handlers": handler_names}, + "root": {"level": level, "handlers": root_handler_names}, } + + # --- Model-layer routing: bind whitelisted loggers to the model_call file --- + # propagate=False keeps their records out of the service category files; + # console is attached as well so stdout behaviour stays unchanged. Levels + # are not set here: the whitelisted loggers inherit the root LOG_LEVEL, so + # model-body records must be logged at INFO to reach the file at INFO. + if MODEL_CALL_CATEGORY in categories: + config["loggers"] = { + name: {"handlers": ["console", "file_model_call"], "propagate": False} + for name in MODEL_CALL_LOGGERS + } return config diff --git a/backend/utils/mcp_url_utils.py b/backend/utils/mcp_url_utils.py new file mode 100644 index 0000000000..bed8eaf694 --- /dev/null +++ b/backend/utils/mcp_url_utils.py @@ -0,0 +1,15 @@ +"""Utilities for constructing tenant-scoped MCP URLs.""" + +from urllib.parse import quote, urljoin + +from consts import const + + +def get_tenant_local_mcp_server(tenant_id: str) -> str: + """Return the tenant-scoped SSE endpoint for built-in/API-converted tools.""" + if not tenant_id: + raise ValueError("tenant_id is required for the local MCP endpoint") + return urljoin( + const.LOCAL_MCP_SERVER.rstrip("/") + "/", + f"mcp/{quote(str(tenant_id), safe='')}/sse", + ) diff --git a/backend/utils/monitoring_identity.py b/backend/utils/monitoring_identity.py new file mode 100644 index 0000000000..9e04c7421c --- /dev/null +++ b/backend/utils/monitoring_identity.py @@ -0,0 +1,22 @@ +"""Resolve display identity without changing authorization or accounting IDs.""" + +import logging + +from database.user_tenant_db import get_user_tenant_in_tenant + +logger = logging.getLogger(__name__) + + +def resolve_monitoring_user_email(user_id: str, tenant_id: str) -> str | None: + """Use the tenant's stored email; enrichment must not prevent a run.""" + if not user_id or not tenant_id: + return None + try: + user = get_user_tenant_in_tenant(user_id, tenant_id) + email = user.get("user_email") if user else None + if isinstance(email, str): + return email.strip() or None + return None + except Exception: # noqa: BLE001 - Optional enrichment must not interrupt execution. + logger.debug("Unable to resolve monitoring user email") + return None diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index ec390e20e0..2b1567d6e1 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -70,6 +70,7 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw - 'document_summary': Document summary template (Map stage) - 'cluster_summary_reduce': Cluster summary reduce template (Reduce stage) - 'nl2agent': NL2Agent runtime system prompt + - 'workbench_main': General workbench system Agent prompt language: Language code ('zh' or 'en') **kwargs: Additional parameters, for agent type need to pass is_manager parameter @@ -133,6 +134,10 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw LANGUAGE["ZH"]: 'backend/prompts/nl2agent_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/nl2agent_en.yaml' }, + 'workbench_main': { + LANGUAGE["ZH"]: 'backend/prompts/workbench_main_zh.yaml', + LANGUAGE["EN"]: 'backend/prompts/workbench_main_en.yaml' + }, 'evaluation_generate_evaluator': { LANGUAGE["ZH"]: 'backend/prompts/evaluation/generate_evaluator_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/evaluation/generate_evaluator_en.yaml' diff --git a/backend/utils/reasoning.py b/backend/utils/reasoning.py new file mode 100644 index 0000000000..13df0cbb19 --- /dev/null +++ b/backend/utils/reasoning.py @@ -0,0 +1,102 @@ +"""Shared normalization for canonical reasoning settings.""" + +from copy import deepcopy +from typing import Any, Dict, Optional + + +REASONING_PARAM_KEYS = ( + "enable_thinking", + "reasoning_effort", + "reasoning_budget_tokens", +) + + +def _controls(capability: Optional[dict]) -> list[dict]: + """Return the declared reasoning controls in a backward-compatible shape.""" + if not isinstance(capability, dict) or capability.get("status") != "supported": + return [] + declared = capability.get("controls") + if isinstance(declared, list) and declared: + return [control for control in declared if isinstance(control, dict)] + levels = capability.get("levels") or [] + if levels: + return [{"type": "effort", "values": list(levels)}] + if capability.get("control") == "toggle": + return [{"type": "toggle"}] + return [] + + +def supports_reasoning(capability: Optional[dict]) -> bool: + """Whether the catalog explicitly declares a usable reasoning control.""" + return bool(_controls(capability)) + + +def _control(capability: Optional[dict], control_type: str) -> Optional[dict]: + return next( + (control for control in _controls(capability) if control.get("type") == control_type), + None, + ) + + +def normalize_reasoning_params( + extra_params: Optional[Dict[str, Any]], + capability: Optional[dict], +) -> Dict[str, Any]: + """Normalize persisted canonical reasoning fields against model capability. + + Reasoning is intentionally fail-closed: a model without an explicit + catalog capability never receives stale reasoning fields from an old row. + When both controls are declared, a positive numeric budget is the active + control and the effort enum is removed. + """ + normalized = deepcopy(extra_params) if isinstance(extra_params, dict) else {} + if not supports_reasoning(capability): + for key in REASONING_PARAM_KEYS: + normalized.pop(key, None) + return normalized + + effort_control = _control(capability, "effort") + budget_control = _control(capability, "budget_tokens") + enabled = normalized.get("enable_thinking") + + if enabled is False: + normalized.pop("reasoning_effort", None) + normalized.pop("reasoning_budget_tokens", None) + return normalized + + budget = normalized.get("reasoning_budget_tokens") + has_budget = ( + budget_control is not None + and isinstance(budget, int) + and not isinstance(budget, bool) + and budget > 0 + ) + if has_budget: + minimum = budget_control.get("min") + maximum = budget_control.get("max") + if isinstance(minimum, int) and isinstance(maximum, int): + normalized["reasoning_budget_tokens"] = min(maximum, max(minimum, budget)) + normalized.pop("reasoning_effort", None) + else: + normalized.pop("reasoning_budget_tokens", None) + effort = normalized.get("reasoning_effort") + if effort_control is None: + normalized.pop("reasoning_effort", None) + elif effort is not None: + values = {str(value) for value in effort_control.get("values") or []} + values.add("auto") + if str(effort) not in values: + normalized["reasoning_effort"] = "auto" + + # Historical rows may have only an effort/budget field. Preserve their + # meaning for supported models while keeping unsupported rows fail-closed. + if enabled is None and ( + "reasoning_effort" in normalized or "reasoning_budget_tokens" in normalized + ): + normalized["enable_thinking"] = True + return normalized + + +def reasoning_controls(capability: Optional[dict]) -> list[dict]: + """Expose normalized controls to callers that need to validate a value.""" + return _controls(capability) diff --git a/backend/utils/runtime_config_utils.py b/backend/utils/runtime_config_utils.py new file mode 100644 index 0000000000..ea9f27af61 --- /dev/null +++ b/backend/utils/runtime_config_utils.py @@ -0,0 +1,41 @@ +"""Copy runtime configuration containers without cloning live service handles.""" + +from copy import copy +from dataclasses import fields, is_dataclass +from typing import Any + +from pydantic import BaseModel + + +def clone_runtime_config(value: Any, memo: dict[int, Any] | None = None) -> Any: + """Isolate mutable config data; opaque clients, locks and callbacks stay request-local references.""" + memo = {} if memo is None else memo + if id(value) in memo: + return memo[id(value)] + if isinstance(value, BaseModel): + result = value.model_copy() + memo[id(value)] = result + for name, item in value.__dict__.items(): + object.__setattr__(result, name, clone_runtime_config(item, memo)) + return result + if isinstance(value, dict): + result = {} + memo[id(value)] = result + result.update((key, clone_runtime_config(item, memo)) for key, item in value.items()) + return result + if isinstance(value, list): + result = [] + memo[id(value)] = result + result.extend(clone_runtime_config(item, memo) for item in value) + return result + if isinstance(value, tuple): + return tuple(clone_runtime_config(item, memo) for item in value) + if isinstance(value, set): + return {clone_runtime_config(item, memo) for item in value} + if is_dataclass(value) and not isinstance(value, type): + result = copy(value) + memo[id(value)] = result + for field in fields(value): + object.__setattr__(result, field.name, clone_runtime_config(getattr(value, field.name), memo)) + return result + return value diff --git a/deploy/common/common.sh b/deploy/common/common.sh index 81c5c2b391..bf88e2bbae 100755 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -2235,7 +2235,7 @@ deployment_render_helm_monitoring_global_values() { printf ' langsmithOtlpTracesEndpoint: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value LANGSMITH_OTLP_TRACES_ENDPOINT "https://api.smith.langchain.com/otel/v1/traces")")" printf ' otlpMetricsEnabled: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value OTEL_EXPORTER_OTLP_METRICS_ENABLED "true")")" printf ' instrumentRequests: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value MONITORING_INSTRUMENT_REQUESTS "false")")" - printf ' fastapiIncludedUrls: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value MONITORING_FASTAPI_INCLUDED_URLS "/agent/run")")" + printf ' fastapiIncludedUrls: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value MONITORING_FASTAPI_INCLUDED_URLS "/agent/run,/conversation/generate_title,/nb/v1/generate_title")")" printf ' fastapiExcludedUrls: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value MONITORING_FASTAPI_EXCLUDED_URLS "")")" printf ' fastapiExcludeSpans: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value MONITORING_FASTAPI_EXCLUDE_SPANS "receive,send")")" printf ' telemetrySampleRate: %s\n' "$(deployment_yaml_quote "$(deployment_monitoring_env_value TELEMETRY_SAMPLE_RATE "1.0")")" diff --git a/deploy/deploy-official-agents.sh b/deploy/deploy-official-agents.sh new file mode 100755 index 0000000000..173858e28f --- /dev/null +++ b/deploy/deploy-official-agents.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +# Deploy official Agent bundles bundled with the Nexent repository. +# Run this script after Nexent itself is installed and ready. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SOURCE_ROOT="$ROOT_DIR/deploy/official-agents" +TARGET_CONTAINER="nexent-config" +TARGET_CONTAINER_DIR="/mnt/nexent/official-agents" +NAMESPACE="nexent" +DEPLOY_OFFICIAL_K8S=false +PROFILES="" + +usage() { + cat <<'EOF' +Usage: deploy-official-agents.sh [options] + +Official Agent bundles are read from: + deploy/official-agents + +Options: + --kubernetes Sync through kubectl instead of Docker + --namespace NAME Kubernetes namespace (default: nexent) + -h, --help Show this help + +Without options, the script interactively asks which Agent profiles to deploy. +EOF +} + +die() { + printf 'ERROR: %s\n' "$*" + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --kubernetes) DEPLOY_OFFICIAL_K8S=true; shift ;; + --namespace) NAMESPACE="${2:?missing value for --namespace}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -d "$SOURCE_ROOT" ] || die "official Agent directory not found: $SOURCE_ROOT" + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/nexent-official-agents.XXXXXX")" +trap 'rm -rf "$TMP_ROOT"' EXIT + +declare -a AVAILABLE_DIRS=() +declare -a AVAILABLE_NAMES=() + +load_profiles() { + local path + while IFS= read -r -d '' path; do + AVAILABLE_DIRS+=("$path") + AVAILABLE_NAMES+=("$(basename "$path")") + done < <(find "$SOURCE_ROOT" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z) + [ "${#AVAILABLE_DIRS[@]}" -gt 0 ] || die "no official Agent profiles found in $SOURCE_ROOT" +} + +select_profiles() { + local i selection token index found name + load_profiles + + printf '\nAvailable official Agent profiles:\n' + for i in "${!AVAILABLE_NAMES[@]}"; do + printf ' %d) %s\n' "$((i + 1))" "${AVAILABLE_NAMES[$i]}" + done + printf '\n' + read -r -p "Select profiles (comma-separated numbers, or all): " selection + [ -n "$selection" ] || die "no profiles selected" + + if [ "$selection" = "all" ] || [ "$selection" = "ALL" ]; then + PROFILES="$(IFS=,; printf '%s' "${AVAILABLE_NAMES[*]}")" + return + fi + + local -a selected_names=() + IFS=',' read -r -a tokens <<< "$selection" + for token in "${tokens[@]}"; do + token="$(printf '%s' "$token" | xargs)" + [[ "$token" =~ ^[0-9]+$ ]] || die "invalid profile selection: $token" + index=$((token - 1)) + [ "$index" -ge 0 ] && [ "$index" -lt "${#AVAILABLE_NAMES[@]}" ] \ + || die "profile selection out of range: $token" + found=false + for name in "${selected_names[@]}"; do + [ "$name" = "${AVAILABLE_NAMES[$index]}" ] && found=true + done + [ "$found" = true ] || selected_names+=("${AVAILABLE_NAMES[$index]}") + done + [ "${#selected_names[@]}" -gt 0 ] || die "no profiles selected" + PROFILES="$(IFS=,; printf '%s' "${selected_names[*]}")" +} + +copy_profile_to_docker() { + local profile="$1" source="$2" target docker_source + target="$TMP_ROOT/staged/$profile" + mkdir -p "$target" + cp -R "$source/." "$target/" + + command -v docker >/dev/null 2>&1 || die "docker is required for Docker deployment" + docker inspect "$TARGET_CONTAINER" >/dev/null 2>&1 || die "container not found: $TARGET_CONTAINER" + MSYS_NO_PATHCONV=1 docker exec "$TARGET_CONTAINER" rm -rf "$TARGET_CONTAINER_DIR/$profile" + MSYS_NO_PATHCONV=1 docker exec "$TARGET_CONTAINER" mkdir -p "$TARGET_CONTAINER_DIR/$profile" + docker_source="$target" + if command -v cygpath >/dev/null 2>&1; then + docker_source="$(cygpath -w "$target")" + fi + MSYS_NO_PATHCONV=1 docker cp "$docker_source/." "$TARGET_CONTAINER:$TARGET_CONTAINER_DIR/$profile/" +} + +copy_profile_to_kubernetes() { + local profile="$1" source="$2" + command -v kubectl >/dev/null 2>&1 || die "kubectl is required for Kubernetes deployment" + kubectl get deployment/nexent-config -n "$NAMESPACE" >/dev/null 2>&1 || die "Kubernetes deployment nexent-config not found in namespace $NAMESPACE" + local nexent_user_dir="${NEXENT_USER_DIR:-$HOME/nexent}" + local target="$nexent_user_dir/official-agents/$profile" + rm -rf "$target" + mkdir -p "$target" + cp -R "$source/." "$target/" + printf 'Copied %s to %s (Kubernetes persistent directory)\n' "$profile" "$target" +} + +copy_profiles() { + local profile source + IFS=',' read -r -a selected <<< "$PROFILES" + for profile in "${selected[@]}"; do + source="$SOURCE_ROOT/$profile" + [ -d "$source" ] || die "profile directory not found: $profile" + find "$source" -type f -name agent.json -print -quit | grep -q . || die "profile has no agent.json: $profile" + if [ "$DEPLOY_OFFICIAL_K8S" = true ]; then + copy_profile_to_kubernetes "$profile" "$source" + else + copy_profile_to_docker "$profile" "$source" + fi + done +} + +sync_repository() { + local response synchronized + if [ "$DEPLOY_OFFICIAL_K8S" = true ]; then + response="$(kubectl exec deployment/nexent-config -n "$NAMESPACE" -- curl -fsS -X POST --get --data-urlencode "profiles=$PROFILES" http://127.0.0.1:5010/repository/agent/internal/official/sync)" || die "official Agent synchronization request failed" + else + response="$(docker exec "$TARGET_CONTAINER" curl -fsS -X POST --get --data-urlencode "profiles=$PROFILES" http://127.0.0.1:5010/repository/agent/internal/official/sync)" || die "official Agent synchronization request failed" + fi + synchronized="$(printf '%s' "$response" | sed -n 's/.*"synchronized"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')" + [ -n "$synchronized" ] || die "invalid official Agent synchronization response: $response" + printf 'Synchronized %s official Agent bundle(s)\n' "$synchronized" +} + +select_profiles +copy_profiles +sync_repository +printf 'Official Agent deployment completed for profiles: %s\n' "$PROFILES" diff --git a/deploy/docker/compose/docker-compose.yml b/deploy/docker/compose/docker-compose.yml index 1ee521ad66..08605b5feb 100644 --- a/deploy/docker/compose/docker-compose.yml +++ b/deploy/docker/compose/docker-compose.yml @@ -97,6 +97,9 @@ services: <<: [*minio-vars, *es-vars] NEXENT_SQL_STARTUP_MODE: migrate NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} + # Always use the container path. A developer's backend/.env may contain + # a host-only Windows path, which is not valid inside this container. + OFFICIAL_AGENTS_PATH: /mnt/nexent/official-agents skip_proxy: "true" UMASK: "0022" env_file: diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 20ef1f9dbd..978759eeda 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -76,6 +76,25 @@ POSTGRES_PORT=5432 # Default Super Admin Config NEXENT_SUPER_ADMIN_PASSWORD=Nexent@123 +# Tenant resource limits. Values override the application defaults. +MAX_TENANT_COUNT=100 +MAX_USERS_PER_TENANT=10000 +MAX_GROUPS_PER_TENANT=1000 +MAX_SUPER_ADMIN_COUNT=1 +MAX_ADMINS_PER_TENANT=1000 +MAX_KNOWLEDGE_BASES_PER_TENANT=10000 +MAX_KNOWLEDGE_BASES_PER_USER=10 +MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER=1000 +MAX_KNOWLEDGE_FILE_SIZE_MB=100 +MAX_CONVERSATION_TURNS=100 +MAX_CONVERSATIONS_PER_USER=1000 +# MCP resource limit and connection timeout. The timeout only covers +# connection establishment and the MCP initialization handshake; tool +# execution uses the independent runtime timeout configuration. +MAX_MCP_SERVICES_PER_TENANT=1000 +MCP_REQUEST_TIMEOUT_SECONDS=10 +MAX_EVALUATION_SET_FILE_SIZE_MB=20 + # Minio Config MINIO_ENDPOINT=http://nexent-minio:9000 MINIO_ROOT_USER=nexent @@ -143,6 +162,7 @@ RUNTIME_AGENT_THREAD_MAX_QUEUE_SIZE=32 RUNTIME_AGENT_THREAD_QUEUE_TIMEOUT_SECONDS=30 RUNTIME_AGENT_THREAD_CANCEL_GRACE_SECONDS=5 RUNTIME_MCP_TOOL_TIMEOUT_SECONDS=60 +RUNTIME_PARALLEL_EXECUTOR_TIMEOUT_SECONDS=120 RUNTIME_MCP_CLOSE_TIMEOUT_SECONDS=5 RUNTIME_THREAD_SHUTDOWN_GRACE_SECONDS=30 NORTHBOUND_CONTROL_THREAD_MAX_WORKERS=8 @@ -275,6 +295,15 @@ AIDP_TENANT_ID=aidp # empty, the backend uses JWT_SECRET so existing deployments need no change. IND_AIDP_IMAGE_SIGNING_KEY= +# ===== Agent Workbench ===== +# Enable the tenant-scoped Agent Workbench, its navigation entry, and runtime APIs. +# When disabled, Start Chat and ordinary Agent conversations remain available. +ENABLE_AGENT_WORKBENCH=false + +# Hide the Home navigation entry. When enabled, the root URL opens Agent Workbench +# if available, or Start Chat otherwise. Independent of ENABLE_AGENT_WORKBENCH. +HIDE_HOME_PAGE=false + # ===== Agent Sandbox Configuration ===== # Default sandbox isolation level: local / docker / wasm. @@ -312,6 +341,18 @@ NEXENT_SANDBOX_SHELL_POLICY=disabled # MinIO bucket for sandbox output file sync. NEXENT_SANDBOX_OUTPUT_BUCKET=nexent-artifacts +# ===== Official Agent Configuration ===== + +# Official Agent bundles mounted into the config service container. +# The independent deploy/deploy-official-agents.sh script manages the files +# under this directory after Nexent has been deployed. +OFFICIAL_AGENTS_PATH=/mnt/nexent/official-agents + +# Optional fallback profile list for direct synchronization calls. +# Normally leave this empty; deploy-official-agents.sh passes the selected +# profiles at runtime through --profiles. +OFFICIAL_AGENT_PROFILES= + # Automatically sync sandbox output files to MinIO after each run. NEXENT_SANDBOX_AUTO_SYNC_OUTPUTS=true @@ -322,9 +363,16 @@ AGENT_WORKSPACE_ROOT=/mnt/nexent/workdir # Website File Upload Size Limit (10 - 100MB) FILE_UPLOAD_SIZE_LIMIT=100 +# Tenant Skill resource limits. Backend defaults are used when these values are omitted. +MAX_SKILLS_PER_TENANT=1000 +MAX_SKILL_UPLOAD_SIZE_MB=10 + # ===== Logging Configuration ===== # Unified across config / runtime / mcp / data_process / northbound services. # Each service writes to its own subdirectory under LOG_DIR. +# The model_call category is written by the runtime service: records from the +# SDK model-layer loggers (LLM/STT/TTS/VLM calls) land in model_call/ instead +# of runtime/, keeping model logs separate from system logs. # When true, force DEBUG level for all category loggers (overrides LOG_LEVEL) IS_DEBUG=false diff --git a/deploy/env/monitoring.env.example b/deploy/env/monitoring.env.example index 0d4c317866..57e16a6b2b 100644 --- a/deploy/env/monitoring.env.example +++ b/deploy/env/monitoring.env.example @@ -16,7 +16,7 @@ OTEL_EXPORTER_OTLP_X_API_KEY= OTEL_EXPORTER_OTLP_LANGFUSE_INGESTION_VERSION= OTEL_EXPORTER_OTLP_METRICS_ENABLED=true MONITORING_INSTRUMENT_REQUESTS=false -MONITORING_FASTAPI_INCLUDED_URLS=/agent/run +MONITORING_FASTAPI_INCLUDED_URLS=/agent/run,/conversation/generate_title,/nb/v1/generate_title MONITORING_FASTAPI_EXCLUDED_URLS= MONITORING_FASTAPI_EXCLUDE_SPANS=receive,send TELEMETRY_SAMPLE_RATE=1.0 diff --git a/deploy/images/build.sh b/deploy/images/build.sh index e52e8a93cd..1b66edcc5d 100755 --- a/deploy/images/build.sh +++ b/deploy/images/build.sh @@ -494,6 +494,7 @@ build_one() { if [ -n "$PLATFORM" ]; then cmd+=(--platform "$PLATFORM") fi + cmd+=(--pull=false) if [ "$NO_CACHE" = true ] || { [ "$REGISTRY" = "mainland" ] && [ "$name" = "nexent-web" ]; }; then cmd+=(--no-cache) fi @@ -510,7 +511,9 @@ build_one() { build_selected_image() { case "$1" in main) build_one nexent "$DOCKERFILE_DIR/main/Dockerfile" "${PY_MIRROR_ARGS[@]}" ;; - web) build_one nexent-web "$DOCKERFILE_DIR/web/Dockerfile" "${WEB_MIRROR_ARGS[@]}" ;; + web) build_one nexent-web "$DOCKERFILE_DIR/web/Dockerfile" \ + --build-arg MAX_KNOWLEDGE_FILE_SIZE_MB="${MAX_KNOWLEDGE_FILE_SIZE_MB:-100}" \ + "${WEB_MIRROR_ARGS[@]}" ;; docs) build_one nexent-docs "$DOCKERFILE_DIR/docs/Dockerfile" "${WEB_MIRROR_ARGS[@]}" ;; data-process) local image_name="nexent-data-process" diff --git a/deploy/images/dockerfiles/main/Dockerfile b/deploy/images/dockerfiles/main/Dockerfile index 135ed283c0..363dd25d70 100644 --- a/deploy/images/dockerfiles/main/Dockerfile +++ b/deploy/images/dockerfiles/main/Dockerfile @@ -52,6 +52,7 @@ RUN --mount=type=cache,id=nexent-main-uv-${TARGETARCH},target=/root/.cache/uv,sh uv pip install --link-mode copy "/opt/sdk[performance]" $(test -n "$MIRROR" && echo "-i $MIRROR") FROM base AS final +ARG MODELS_DEV_CATALOG_URL=https://models.dev/catalog.json ENV VIRTUAL_ENV=/opt/backend/.venv ENV PATH="$VIRTUAL_ENV/bin:$PATH" @@ -68,6 +69,11 @@ COPY VERSION /opt/nexent/VERSION COPY deploy/common/run-sql-migrations.sh deploy/common/start-backend.sh /opt/nexent/scripts/ RUN chmod +x /opt/nexent/scripts/run-sql-migrations.sh /opt/nexent/scripts/start-backend.sh +# Bundle the models.dev reasoning capability catalog at build time so runtime +# model matching works without requiring outbound network access. +RUN curl -fsSL --proto '=https' -o /opt/backend/configs/models_dev_catalog.json \ + "$MODELS_DEV_CATALOG_URL" + # Bundle the LiteLLM model catalog (3818 models) so suggest_capacity can look # up context/max_output for models not in capability_profiles.py. Fetched at # build time so it works offline / without VPN at runtime. If the build host diff --git a/deploy/images/dockerfiles/web/Dockerfile b/deploy/images/dockerfiles/web/Dockerfile index 5c3707d73c..6625824de2 100644 --- a/deploy/images/dockerfiles/web/Dockerfile +++ b/deploy/images/dockerfiles/web/Dockerfile @@ -1,10 +1,12 @@ # Build stage -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder ARG MIRROR ARG TARGETARCH ARG CONFIGURED_BASE_PATH=/ +ARG MAX_KNOWLEDGE_FILE_SIZE_MB=100 ENV NEXT_PUBLIC_BASE_PATH=${CONFIGURED_BASE_PATH} +ENV NEXT_PUBLIC_KNOWLEDGE_BASE_MAX_FILE_SIZE_MB=${MAX_KNOWLEDGE_FILE_SIZE_MB} # Build Next.js application WORKDIR /opt/frontend @@ -51,7 +53,7 @@ RUN --mount=type=cache,id=nexent-web-next-${TARGETARCH},target=/opt/frontend/.ne rm -rf ../frontend-dist/.next/cache # Production stage -FROM node:20-alpine +FROM node:22-alpine ARG APK_MIRROR ARG TARGETARCH ARG CONFIGURED_BASE_PATH=/ @@ -69,7 +71,7 @@ RUN --mount=type=cache,id=nexent-web-apk-${TARGETARCH},target=/var/cache/apk,sha mkdir -p /var/cache/apk && \ apk update && \ (apk upgrade busybox || true) && \ - apk add --no-scripts curl + apk add --no-scripts curl vim WORKDIR /opt/frontend-dist diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh index 5614572a4a..bb46added7 100755 --- a/deploy/k8s/deploy.sh +++ b/deploy/k8s/deploy.sh @@ -484,6 +484,11 @@ render_k8s_runtime_config_values() { printf ' umask: %s\n' "$(yaml_quote "$(env_or_default UMASK "0022")")" printf ' skillsPath: %s\n' "$(yaml_quote "$(env_or_default SKILLS_PATH "/mnt/nexent-data/skills")")" printf ' logDir: %s\n' "$(yaml_quote "$(env_or_default LOG_DIR "/mnt/nexent-data/logs")")" + echo " skill:" + printf ' maxSkillsPerTenant: %s\n' "$(yaml_quote "$(env_or_default MAX_SKILLS_PER_TENANT "1000")")" + printf ' maxUploadSizeMb: %s\n' "$(yaml_quote "$(env_or_default MAX_SKILL_UPLOAD_SIZE_MB "10")")" + echo " evaluationSet:" + printf ' maxFileSizeMb: %s\n' "$(yaml_quote "$(env_or_default MAX_EVALUATION_SET_FILE_SIZE_MB "20")")" echo " modelEngine:" printf ' enabled: %s\n' "$(yaml_quote "$(env_or_default MODEL_ENGINE_ENABLED "false")")" echo " voiceService:" diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml index c5c2d9afc4..1fea803178 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml @@ -154,6 +154,9 @@ data: SKILLS_PATH: {{ .Values.config.skillsPath | quote }} MEMORY_PROVIDER_PLUGINS_DIR: {{ .Values.config.memoryProviderPluginsDir | quote }} LOG_DIR: {{ .Values.config.logDir | quote }} + MAX_SKILLS_PER_TENANT: {{ .Values.config.skill.maxSkillsPerTenant | quote }} + MAX_SKILL_UPLOAD_SIZE_MB: {{ .Values.config.skill.maxUploadSizeMb | quote }} + MAX_EVALUATION_SET_FILE_SIZE_MB: {{ .Values.config.evaluationSet.maxFileSizeMb | quote }} # MCP Container Image NEXENT_MCP_DOCKER_IMAGE: {{ printf "%s:%s" .Values.images.mcp.repository .Values.images.mcp.tag | quote }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml index f585004e6f..bfc30a1166 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml @@ -71,6 +71,11 @@ config: skillsPath: "/mnt/nexent-data/skills" memoryProviderPluginsDir: "/mnt/nexent-data/memory-provider-plugins" logDir: "/mnt/nexent-data/logs" + skill: + maxSkillsPerTenant: "1000" + maxUploadSizeMb: "10" + evaluationSet: + maxFileSizeMb: "20" modelEngine: enabled: "false" voiceService: diff --git a/deploy/k8s/helm/nexent/values.yaml b/deploy/k8s/helm/nexent/values.yaml index 3df1d7a409..d2ca2bfc31 100644 --- a/deploy/k8s/helm/nexent/values.yaml +++ b/deploy/k8s/helm/nexent/values.yaml @@ -54,7 +54,7 @@ global: langsmithProject: "nexent" otlpMetricsEnabled: true instrumentRequests: false - fastapiIncludedUrls: "/agent/run" + fastapiIncludedUrls: "/agent/run,/conversation/generate_title,/nb/v1/generate_title" fastapiExcludedUrls: "" fastapiExcludeSpans: "receive,send" dashboardUrl: "" diff --git a/deploy/official-agents/government/bid_analysis_assistant/agent.json b/deploy/official-agents/government/bid_analysis_assistant/agent.json new file mode 100644 index 0000000000..e10de0d4ff --- /dev/null +++ b/deploy/official-agents/government/bid_analysis_assistant/agent.json @@ -0,0 +1,115 @@ +{ + "agent_id": 3165, + "agent_info": { + "3165": { + "agent_id": 3165, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "bid_analysis_assistant", + "display_name": "【政务】招投标评审助手", + "description": "你是一个招投标评审专家,能调用招投标知识库智能解析用户上传的文件,并快速完成合规性校验、缺漏项识别、响应度评估与风险排查。", + "author": "mlh@dev.com", + "max_steps": 30, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "guardrail_config": null, + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一个招投标智能评审专家,专注于对招投标文件进行深度解析与专业评估。 \n你能够结合招投标法规与评分细则,对文件进行合规性校验、缺漏项识别、响应度评估及风险点排查。 \n你可以通过智能分析招投标文件,生成结构化的评审结果,帮助用户提升评审效率并降低合规风险。", + "constraint_prompt": "1. 工具调用必须严格遵循知识库名称配置,使用 `knowledge_base_search` 时,`index_names` 参数必须指定为 `[\"招投标知识库\"]`,不得使用其他名称。\n2. 每次调用 `knowledge_base_search` 时,必须提供明确、具体的查询词,查询词应聚焦于招投标法规、合规性要求、评审标准、风险条款等,避免模糊或宽泛的提问。\n3. 对用户上传的招投标文件进行解析时,必须优先使用 `analyze_text_file` 工具,将文件 URL 和具体的分析指令(如“检查合规性”、“识别缺漏项”等)传入,不得跳过该工具直接猜测文件内容。\n4. 当分析任务需要结合招投标专业知识时,必须先用 `knowledge_base_search` 获取相关知识,再基于知识库结果和文件分析结果进行综合判断,不得仅凭通用常识给出结论。\n5. 禁止在分析过程中虚构或假设招投标文件内容,所有校验、评估和风险排查必须基于工具返回的实际文件内容与知识库信息。\n6. 若文件分析或知识库搜索返回的信息不足以完成某项评估(如无法确认某条款是否合规),必须如实说明信息不足,并给出需要补充信息的建议,不得强行输出确定性的结论。\n7. 工具调用顺序应遵循“先搜知识库,后分析文件,最后综合评估”的原则,确保分析过程有依据、可追溯。\n8. 最终评审结果必须通过create-docx skill 生成可下载word文档,供用户下载。", + "few_shots_prompt": "任务1:\"检查这份招标文件是否存在合规性风险\"\n\n思考:我需要先分析用户上传的招标文件,同时查询招投标知识库中相关的法规要求,以便进行合规性校验。\n代码:\n\nresults = parallel_executor(\n tasks=[\n (analyze_text_file, {\"file_url_list\": [\"s3://bidding-files/招标文件.pdf\"], \"query\": \"提取招标文件中的关键条款、资质要求、评分标准\"}, \"file_analysis\"),\n (knowledge_base_search, {\"query\": \"招标文件合规性要求 法律法规 强制性条款\", \"index_names\": [\"招投标知识库\"]}, \"kb_result\"),\n ],\n)\nprint(results[\"file_analysis\"])\nprint(results[\"kb_result\"])\n\n\n思考:我已获得招标文件内容和相关法规要求,现在可以进行合规性校验,识别潜在风险点。\n根据对招标文件的分析,结合招投标知识库中的法规要求,我已完成合规性校验。主要发现以下风险点:1. 资质要求中未明确中小企业扶持政策,与《政府采购促进中小企业发展管理办法》存在偏差;2. 评分标准中价格分权重超过30%,需关注是否设置异常低价评审机制;3. 合同条款中履约保证金比例超过10%,超出《招标投标法实施条例》规定的上限。最终评审文件已生成,请下载查看。\n\n---\n\n任务2:\"帮我识别这份投标文件中的缺漏项\"\n\n思考:我需要分析投标文件内容,同时查询招投标知识库中关于投标文件完整性的要求。\n代码:\n\nresults = parallel_executor(\n tasks=[\n (analyze_text_file, {\"file_url_list\": [\"s3://bidding-files/投标文件.pdf\"], \"query\": \"提取投标文件中的所有章节、附件、证明材料清单\"}, \"file_analysis\"),\n (knowledge_base_search, {\"query\": \"投标文件必备内容 缺漏项 完整性要求\", \"index_names\": [\"招投标知识库\"]}, \"kb_result\"),\n ],\n)\nprint(results[\"file_analysis\"])\nprint(results[\"kb_result\"])\n\n\n思考:我已获得投标文件结构和知识库中的完整性要求,现在进行缺漏项比对。\n根据对投标文件的分析,结合招投标知识库中的完整性要求,识别出以下缺漏项:1. 缺少法定代表人授权委托书原件;2. 近三年财务审计报告仅提供两年,缺少2022年度报告;3. 未提供项目负责人无在建工程承诺函;4. 技术方案中缺少质量保证措施章节。评审文件已生成,请下载查看。\n\n---\n\n任务3:\"评估这份投标文件对招标要求的响应度\"\n\n思考:我需要同时获取投标文件内容和招标文件要求,以便进行响应度对比评估。\n代码:\n\nresults = parallel_executor(\n tasks=[\n (analyze_text_file, {\"file_url_list\": [\"s3://bidding-files/投标文件.pdf\"], \"query\": \"提取投标文件中的所有技术参数、商务条款响应、服务承诺\"}, \"bid_analysis\"),\n (analyze_text_file, {\"file_url_list\": [\"s3://bidding-files/招标文件.pdf\"], \"query\": \"提取招标文件中的技术要求、商务条款、服务要求\"}, \"tender_analysis\"),\n (knowledge_base_search, {\"query\": \"投标响应度评估标准 偏差认定 评分方法\", \"index_names\": [\"招投标知识库\"]}, \"kb_result\"),\n ],\n)\nprint(results[\"bid_analysis\"])\nprint(results[\"tender_analysis\"])\nprint(results[\"kb_result\"])\n\n\n思考:我已完成三方信息获取,现在进行交叉比对,评估响应度。\n根据投标文件与招标要求的对比分析,结合知识库中的评估标准,响应度评估结果如下:1. 技术参数响应率92%,其中3项参数存在负偏差;2. 商务条款完全响应,无偏差;3. 服务承诺中有2项超出招标要求,属于正偏差加分项。整体响应度等级为良好。评审文件已生成,请下载查看。\n\n---\n\n任务4:\"帮我排查这份招投标文件中的全部风险点\"\n\n思考:这是一个综合风险排查任务,我需要同时从多个维度进行分析。\n代码:\n\nresults = parallel_executor(\n tasks=[\n (analyze_text_file, {\"file_url_list\": [\"s3://bidding-files/招标文件.pdf\"], \"query\": \"提取招标文件中的资格条件、评审办法、合同条款、技术要求\"}, \"file_content\"),\n (knowledge_base_search, {\"query\": \"招投标风险点 围标串标 歧视性条款 法律风险 履约风险\", \"index_names\": [\"招投标知识库\"]}, \"risk_standards\"),\n ],\n)\nprint(results[\"file_content\"])\nprint(results[\"risk_standards\"])\n\n\n思考:我已获得文件内容和风险标准知识,现在进行系统性风险排查。\n根据文件内容与招投标知识库中风险标准的交叉比对,排查出以下风险点:1. 资格条件中要求“本地注册企业”,存在地域歧视风险,涉嫌违反《招标投标法》公平原则;2. 评审办法中技术分主观性指标占比过高,易引发质疑投诉;3. 合同条款中违约责任约定不明,存在履约争议风险;4. 技术要求中指定唯一品牌型号,涉嫌限制竞争。评审文件已生成,请下载查看。\n\n---\n\n任务5:\"分析这份招标文件评分细则的合理性\"\n\n思考:我需要先提取评分细则,再从知识库中获取评分细则设计规范进行对比。\n代码:\n\nresults = parallel_executor(\n tasks=[\n (analyze_text_file, {\"file_url_list\": [\"s3://bidding-files/招标文件.pdf\"], \"query\": \"提取评分细则的完整内容,包括评分项、分值、评审标准\"}, \"scoring_detail\"),\n (knowledge_base_search, {\"query\": \"评分细则设计规范 分值设置 客观量化 评审标准\", \"index_names\": [\"招投标知识库\"]}, \"scoring_standards\"),\n ],\n)\nprint(results[\"scoring_detail\"])\nprint(results[\"scoring_standards\"])\n\n\n思考:我已获得评分细则内容和设计规范,现在进行合理性评估。\n根据评分细则与知识库中设计规范的对比分析,合理性评估如下:1. 价格分权重35%,符合常规范围但建议设置价格分计算方法;2. 技术分中“服务方案”占比20%但评审标准过于笼统,建议细化为量化的子项;3. 业绩评分中未区分项目规模,可能导致评分失真;4. 缺少中小企业价格扣除条款,需补充。评审文件已生成,请下载查看。", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-2581" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "AnalyzeTextFileTool", + "name": "analyze_text_file", + "description": "Extract content from text files and analyze them using a large language model based on your query. Supports multiple files from S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs. The tool will extract text content from each file and return an analysis based on your question.", + "inputs": "{\"file_url_list\": {\"type\": \"array\", \"description\": \"List of file URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\", \"description_zh\": \"文件 URL 列表(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"}, \"query\": {\"type\": \"string\", \"description\": \"User's question to guide the analysis\", \"description_zh\": \"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": null + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 7534 + ], + "model_names": [], + "business_logic_model_id": 7534, + "business_logic_model_name": null, + "skill_names": [ + "bidding-compliance-review", + "create-docx", + "analyze-text-file" + ], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是招投标评审助手,可以帮你深度解析招投标文件,快速完成合规性校验、缺漏项识别、响应度评估和风险点排查。", + "example_questions": [ + "检查这份招标文件有没有合规性风险", + "帮我识别投标文件里缺了哪些材料", + "评估一下这份投标文件对招标要求的响应度", + "全面排查这份招投标文件的风险点", + "分析一下招标文件评分细则是否合理" + ] + } + }, + "mcp_info": [], + "name": "bid_analysis_assistant", + "display_name": "【政务】招投标评审助手", + "icon": "📊", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-2581", + "display_name": "招投标知识库", + "description": "", + "documents": [] + } + ] +} diff --git a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/2772f0ed-fca4-07a7-febd-170b96fa466c (1).pdf b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/2772f0ed-fca4-07a7-febd-170b96fa466c (1).pdf new file mode 100644 index 0000000000..6b9b8f05a6 Binary files /dev/null and b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/2772f0ed-fca4-07a7-febd-170b96fa466c (1).pdf differ diff --git a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/2836319ab01f4bf4bf225c729255a588 (1) (1).docx b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/2836319ab01f4bf4bf225c729255a588 (1) (1).docx new file mode 100644 index 0000000000..03a051ef11 Binary files /dev/null and b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/2836319ab01f4bf4bf225c729255a588 (1) (1).docx differ diff --git a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/P020241129533693405377 (1).pdf b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/P020241129533693405377 (1).pdf new file mode 100644 index 0000000000..f00c52722f Binary files /dev/null and b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/P020241129533693405377 (1).pdf differ diff --git a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/ddb8e239-4765-41e0-87fb-3ec018a5d0e1 (1).pdf b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/ddb8e239-4765-41e0-87fb-3ec018a5d0e1 (1).pdf new file mode 100644 index 0000000000..f86b716c86 Binary files /dev/null and b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/ddb8e239-4765-41e0-87fb-3ec018a5d0e1 (1).pdf differ diff --git "a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2661\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\346\226\275\345\267\245\357\274\211 (1).docx" "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2661\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\346\226\275\345\267\245\357\274\211 (1).docx" new file mode 100644 index 0000000000..498399787e Binary files /dev/null and "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2661\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\346\226\275\345\267\245\357\274\211 (1).docx" differ diff --git "a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2662\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\347\233\221\347\220\206\357\274\211 (1).docx" "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2662\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\347\233\221\347\220\206\357\274\211 (1).docx" new file mode 100644 index 0000000000..81f60e7ab9 Binary files /dev/null and "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2662\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\347\233\221\347\220\206\357\274\211 (1).docx" differ diff --git "a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2663\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\345\213\230\345\257\237\350\256\276\350\256\241\357\274\211 (1).docx" "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2663\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\345\213\230\345\257\237\350\256\276\350\256\241\357\274\211 (1).docx" new file mode 100644 index 0000000000..143991f24c Binary files /dev/null and "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\351\231\204\344\273\2663\357\274\232\346\213\233\346\240\207\346\226\207\344\273\266\347\244\272\350\214\203\346\226\207\346\234\254\357\274\210\345\213\230\345\257\237\350\256\276\350\256\241\357\274\211 (1).docx" differ diff --git "a/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\357\274\210\345\217\221\345\224\256\347\250\277\357\274\211ZC25-1250\345\237\272\345\272\247\346\250\241\345\236\213\346\234\215\345\212\241\345\271\263\345\217\260\345\217\212\345\205\254\345\205\261\346\234\215\345\212\241\345\212\237\350\203\275\345\273\272\350\256\276-\345\244\247\346\250\241\345\236\213\346\234\254\345\234\260\345\214\226\351\203\250\347\275\262\343\200\201\351\200\202\351\205\215\345\217\212\351\251\273\345\234\272\350\277\220\347\273\264\346\234\215\345\212\241\346\213\233\346\240\207\345\205\254\345\221\212 (2).docx" "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\357\274\210\345\217\221\345\224\256\347\250\277\357\274\211ZC25-1250\345\237\272\345\272\247\346\250\241\345\236\213\346\234\215\345\212\241\345\271\263\345\217\260\345\217\212\345\205\254\345\205\261\346\234\215\345\212\241\345\212\237\350\203\275\345\273\272\350\256\276-\345\244\247\346\250\241\345\236\213\346\234\254\345\234\260\345\214\226\351\203\250\347\275\262\343\200\201\351\200\202\351\205\215\345\217\212\351\251\273\345\234\272\350\277\220\347\273\264\346\234\215\345\212\241\346\213\233\346\240\207\345\205\254\345\221\212 (2).docx" new file mode 100644 index 0000000000..094e8244f0 Binary files /dev/null and "b/deploy/official-agents/government/bid_analysis_assistant/kb/kb-2581/\357\274\210\345\217\221\345\224\256\347\250\277\357\274\211ZC25-1250\345\237\272\345\272\247\346\250\241\345\236\213\346\234\215\345\212\241\345\271\263\345\217\260\345\217\212\345\205\254\345\205\261\346\234\215\345\212\241\345\212\237\350\203\275\345\273\272\350\256\276-\345\244\247\346\250\241\345\236\213\346\234\254\345\234\260\345\214\226\351\203\250\347\275\262\343\200\201\351\200\202\351\205\215\345\217\212\351\251\273\345\234\272\350\277\220\347\273\264\346\234\215\345\212\241\346\213\233\346\240\207\345\205\254\345\221\212 (2).docx" differ diff --git a/deploy/official-agents/government/bid_analysis_assistant/skills/analyze-text-file.zip b/deploy/official-agents/government/bid_analysis_assistant/skills/analyze-text-file.zip new file mode 100644 index 0000000000..5ff1d9de92 Binary files /dev/null and b/deploy/official-agents/government/bid_analysis_assistant/skills/analyze-text-file.zip differ diff --git a/deploy/official-agents/government/bid_analysis_assistant/skills/bidding-compliance-review.zip b/deploy/official-agents/government/bid_analysis_assistant/skills/bidding-compliance-review.zip new file mode 100644 index 0000000000..489826b763 Binary files /dev/null and b/deploy/official-agents/government/bid_analysis_assistant/skills/bidding-compliance-review.zip differ diff --git a/deploy/official-agents/government/bid_analysis_assistant/skills/create-docx.zip b/deploy/official-agents/government/bid_analysis_assistant/skills/create-docx.zip new file mode 100644 index 0000000000..de4e44449c Binary files /dev/null and b/deploy/official-agents/government/bid_analysis_assistant/skills/create-docx.zip differ diff --git a/deploy/official-agents/government/document_writing_assistant/agent.json b/deploy/official-agents/government/document_writing_assistant/agent.json new file mode 100644 index 0000000000..b0a9ff9e45 --- /dev/null +++ b/deploy/official-agents/government/document_writing_assistant/agent.json @@ -0,0 +1,92 @@ +{ + "agent_id": 16, + "agent_info": { + "16": { + "agent_id": 16, + "tenant_id": "c30c2caf-01d7-48f6-b4a1-1c85eb262c7b", + "name": "document_writing_assistant", + "display_name": "【政务】党政机关公文助手", + "description": "你是一个专业的公文写作助手,能够根据你提供的业务核心信息,一键生成会议通知、工作报告、请示批复等各类正式文稿。我会自动检索公文写作知识库,匹配对应的行文规范与正式语体,确保输出的内容严谨合规、格式统一。", + "business_description": "你是一个公文写作助手,能够基于业务核心信息一键生成会议通知、会议发言稿、工作报告、请示批复等全品类的正式文稿,并按照党政机关公文生成skill严格依据 GB/T 9704-2012《党政机关公文格式》国家标准生成公文格式。请你先识别用户的写作意图,判断用户需要生成哪一类公文,然后根据知识库中的各类公文写作规范或范文模板,自动匹配对应文种的行文逻辑、版式规范与正式语体,输出内容严谨合规、格式统一的公文,最终反馈给用户可下载文件。", + "author": "mlh@dev.com", + "max_steps": 30, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "guardrail_config": null, + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一个专业的公文写作助手,能够精准识别用户的写作需求,并自动生成符合党政机关公文规范的会议通知、发言稿、工作报告等全品类正式文稿。你内置了各类公文的行文逻辑与格式标准,可确保输出内容严谨合规、版式统一,最终提供可下载的标准公文文件。", + "constraint_prompt": "0. 请你使用党政机关公文生成skill来了解公文写作要求。\n1. 在生成任何公文之前,必须首先调用 knowledge_base_search 工具,从“公文写作”知识库中检索该文种对应的写作规范、行文逻辑、版式要求与范文模板,作为内容生成的依据。\n2. knowledge_base_search 工具的 index_names 参数必须指定为 [\"公文写作\"],严禁使用其他索引名称。\n3. 仅当成功获取知识库中的相关规范信息后,才可进行公文内容的撰写,确保内容严谨合规、格式统一。\n4. 所有需要最终交付给用户的正式公文文件,必须通过党政机关公文生成skill生成可下载的文档,不得仅以文本形式输出或使用其他方式生成文件。\n5. 在生成文档时,应严格依据 GB/T 9704-2012《党政机关公文格式》国家标准设置公文格式,并将知识库中检索到的版式规范应用于文档生成。\n6. 不得在未调用知识库的情况下凭空生成公文内容,也不得在生成文档时偏离知识库所规定的正式语体与结构。", + "few_shots_prompt": "### 示例 1:会议通知\n\n**用户输入:** \n帮我生成一份关于召开2024年度安全生产工作会议的通知,会议时间是2024年12月20日上午9点,地点在公司三楼第一会议室,参会人员包括各部门负责人和安全员。\n\n**思考:** \n我需要先检索知识库中关于“会议通知”的写作规范和模板,以确保生成文件符合公文格式要求。 \n**代码:** \n\nnotice_template = knowledge_base_search(query=\"会议通知 模板\", index_names=[\"公文写作\"])\nprint(notice_template)\n\n\n**Observation:** \n找到以下会议通知模板: \n标题:关于召开×××会议的通知 \n主送机关:××× \n正文:为了×××,经研究决定召开×××会议,现将有关事项通知如下: \n一、会议时间:××× \n二、会议地点:××× \n三、参会人员:××× \n四、会议内容:××× \n五、有关要求:××× \n发文机关:××× \n发文日期:××× \n\n**思考:** \n模板已获取,现在我根据用户提供的具体信息填充模板,并调用文档生成助手生成可下载的Word文件。 \n**代码:** \n\nfile = doc_generator_assistant(task=\"根据会议通知模板,生成一份关于召开2024年度安全生产工作会议的通知。会议时间:2024年12月20日上午9点;地点:公司三楼第一会议室;参会人员:各部门负责人和安全员;会议内容:总结年度安全工作,部署下一年任务;要求参会人员提前10分钟入场并携带相关资料。\")\nprint(file)\n\n\n**Observation:** \n文件已生成,下载链接:https://example.com/download/notice_2024_safety.docx \n\n**思考:** \n文件已成功生成,现在可以直接将下载链接和说明反馈给用户。 \n已为您生成《关于召开2024年度安全生产工作会议的通知》Word文件,请点击下载: \n[点击下载通知文件](https://example.com/download/notice_2024_safety.docx)\n\n---\n\n### 示例 2:工作报告\n\n**用户输入:** \n请帮我写一份2024年第三季度市场推广工作报告,主要包含推广成果、预算执行情况、存在问题及下一步计划。\n\n**思考:** \n我先从知识库中检索“工作报告”的写作体例和规范范文。 \n**代码:** \n\nreport_standard = knowledge_base_search(query=\"工作报告 格式 范文\", index_names=[\"公文写作\"])\nprint(report_standard)\n\n\n**Observation:** \n工作报告一般由标题、主送机关、正文(背景、工作完成情况、存在问题、下一步计划)和落款组成。范文示例:…… \n\n**思考:** \n基于检索到的规范,现在调用文档生成助手,根据用户提供的要点生成正式的工作报告文档。 \n**代码:** \n\nfile = doc_generator_assistant(task=\"生成一份2024年第三季度市场推广工作报告。内容需包括:推广成果(完成线上曝光量500万次,线下活动5场,新客转化率提升12%)、预算执行情况(实际支出占预算的92%)、存在问题(区域覆盖不均衡、部分渠道转化低于预期)、下一步计划(加强区域协同、优化高潜力渠道投放)。采用正式公文语体,格式符合GB/T 9704-2012。\")\nprint(file)\n\n\n**Observation:** \n文件已生成,下载链接:https://example.com/download/market_report_q3.docx \n\n**思考:** \n文件已生成,可以直接向用户交付。 \n已为您生成《2024年第三季度市场推广工作报告》Word文件,请点击下载: \n[点击下载工作报告文件](https://example.com/download/market_report_q3.docx)\n\n---\n\n### 示例 3:请示\n\n**用户输入:** \n帮起草一份请示,内容是关于申请追加2024年信息化建设专项资金50万元,因为原预算无法覆盖网络安全升级的需求。\n\n**思考:** \n需要先查询知识库中“请示”类公文的写作规范,以确保格式正确。 \n**代码:** \n\nrequest_format = knowledge_base_search(query=\"请示 公文格式 范文\", index_names=[\"公文写作\"])\nprint(request_format)\n\n\n**Observation:** \n请示一般由标题(关于×××的请示)、主送机关、正文(请示缘由、请示事项、结尾语)、发文机关和日期组成。范文:…… \n\n**思考:** \n已掌握请示的规范格式,现在将用户提供的理由和金额填入模板,生成正式文件。 \n**代码:** \n\nfile = doc_generator_assistant(task=\"生成一份请示文档。标题:关于追加2024年信息化建设专项资金的请示;主送机关:公司董事会;正文缘由:原预算50万元仅覆盖基础运维,近期网络安全升级需额外投入50万元,总计需100万元,以确保系统安全合规;请示事项:申请追加专项资金50万元;结尾语:妥否,请批示。\")\nprint(file)\n\n\n**Observation:** \n文件已生成,下载链接:https://example.com/download/funding_request.docx \n\n**思考:** \n请示文件已生成,直接向用户提供下载即可。 \n已为您生成《关于追加2024年信息化建设专项资金的请示》Word文件,请点击下载: \n[点击下载请示文件](https://example.com/download/funding_request.docx)\n\n---\n\n### 示例 4:发言稿\n\n**用户输入:** \n请帮我生成一份在年终总结表彰大会上的总经理发言稿,时长约5分钟,重点感谢团队、总结成绩并展望明年。\n\n**思考:** \n先从知识库中查询“发言稿”的常见结构和经典范例,以便生成时用语得体。 \n**代码:** \n\nspeech_example = knowledge_base_search(query=\"发言稿 总经理 年终总结 范文\", index_names=[\"公文写作\"])\nprint(speech_example)\n\n\n**Observation:** \n发言稿一般包括:开场敬语、回顾成绩、感谢团队、分析不足、展望未来、结束语。范文示例:…… \n\n**思考:** \n根据检索到的结构和用户要求,调用文档生成助手生成发言稿。 \n**代码:** \n\nfile = doc_generator_assistant(task=\"生成一份总经理在年终总结表彰大会上的发言稿,时长约5分钟。内容要点:开场敬语,感谢全体员工一年来的努力;总结本年度三大突出成绩(营收增长20%、新产品成功上市、客户满意度创新高);展望明年战略方向(深化数字化转型、开拓海外市场);结束语再次致谢并祝福。语体正式且富有感染力,篇幅约800字。\")\nprint(file)\n\n\n**Observation:** \n文件已生成,下载链接:https://example.com/download/gm_speech.docx \n\n**思考:** \n发言稿已生成,可以将文件提供给用户。 \n已为您生成《年终总结表彰大会总经理发言稿》Word文件,请点击下载: \n[点击下载发言稿文件](https://example.com/download/gm_speech.docx)\n\n---\n\n### 示例 5:批复\n\n**用户输入:** \n请起草一份批复,对市场部提交的《关于举办2024年客户答谢活动的请示》表示同意,并明确活动预算控制在15万元以内,活动方案需提前报备。\n\n**思考:** \n必须参考知识库中“批复”的正确格式和行文规范,确保批复具有权威性和合规性。 \n**代码:** \n\napproval_format = knowledge_base_search(query=\"批复 公文格式 范文\", index_names=[\"公文写作\"])\nprint(approval_format)\n\n\n**Observation:** \n批复一般由标题(关于×××的批复)、主送机关、正文(引用来文、批复意见、执行要求)、发文机关和日期构成。范文:…… \n\n**思考:** \n规范已明确,现在基于用户指令生成具体的批复文件。 \n**代码:** \n\nfile = doc_generator_assistant(task=\"生成一份批复。标题:关于同意举办2024年客户答谢活动的批复;主送机关:市场部;正文:你部《关于举办2024年客户答谢活动的请示》收悉。经研究,现批复如下:一、同意举办2024年客户答谢活动。二、活动预算必须控制在15万元以内,不得超支。三、活动方案须于实施前10个工作日报公司审批。此复。\")\nprint(file)\n\n\n**Observation:** \n文件已生成,下载链接:https://example.com/download/approval_reply.docx \n\n**思考:** \n批复文件已生成,直接提供给用户即可。 \n已为您生成《关于同意举办2024年客户答谢活动的批复》Word文件,请点击下载: \n[点击下载批复文件](https://example.com/download/approval_reply.docx)", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-1" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 10 + ], + "model_names": [ + "deepseek-v4-pro" + ], + "business_logic_model_id": 0, + "business_logic_model_name": null, + "skill_names": [ + "党政机关公文生成" + ], + "prompt_template_id": 0, + "prompt_template_name": "system_default" + } + }, + "mcp_info": [], + "name": "document_writing_assistant", + "display_name": "【政务】党政机关公文助手", + "icon": "📝", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-1", + "display_name": "公文写作知识库", + "description": "", + "documents": [] + } + ] +} diff --git a/deploy/official-agents/government/document_writing_assistant/kb/kb-1/P020200622413358638441 (1).docx b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/P020200622413358638441 (1).docx new file mode 100644 index 0000000000..5468ed439c Binary files /dev/null and b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/P020200622413358638441 (1).docx differ diff --git "a/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\344\274\232\350\256\256\345\217\221\350\250\200\347\250\277.docx" "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\344\274\232\350\256\256\345\217\221\350\250\200\347\250\277.docx" new file mode 100644 index 0000000000..fd6fd6a0a6 Binary files /dev/null and "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\344\274\232\350\256\256\345\217\221\350\250\200\347\250\277.docx" differ diff --git "a/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\344\274\232\350\256\256\351\200\232\347\237\245.docx" "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\344\274\232\350\256\256\351\200\232\347\237\245.docx" new file mode 100644 index 0000000000..8bb865c610 Binary files /dev/null and "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\344\274\232\350\256\256\351\200\232\347\237\245.docx" differ diff --git "a/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\345\267\245\344\275\234\346\212\245\345\221\212.docx" "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\345\267\245\344\275\234\346\212\245\345\221\212.docx" new file mode 100644 index 0000000000..54b608321e Binary files /dev/null and "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\345\267\245\344\275\234\346\212\245\345\221\212.docx" differ diff --git "a/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\350\257\267\347\244\272\346\211\271\345\244\215.docx" "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\350\257\267\347\244\272\346\211\271\345\244\215.docx" new file mode 100644 index 0000000000..46aaedadc5 Binary files /dev/null and "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\350\257\267\347\244\272\346\211\271\345\244\215.docx" differ diff --git "a/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\350\257\267\347\244\272\346\211\271\345\244\215\351\200\232\347\237\245.docx" "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\350\257\267\347\244\272\346\211\271\345\244\215\351\200\232\347\237\245.docx" new file mode 100644 index 0000000000..677f449b1e Binary files /dev/null and "b/deploy/official-agents/government/document_writing_assistant/kb/kb-1/\350\257\267\347\244\272\346\211\271\345\244\215\351\200\232\347\237\245.docx" differ diff --git "a/deploy/official-agents/government/document_writing_assistant/skills/\345\205\232\346\224\277\346\234\272\345\205\263\345\205\254\346\226\207\347\224\237\346\210\220.zip" "b/deploy/official-agents/government/document_writing_assistant/skills/\345\205\232\346\224\277\346\234\272\345\205\263\345\205\254\346\226\207\347\224\237\346\210\220.zip" new file mode 100644 index 0000000000..1c462a23df Binary files /dev/null and "b/deploy/official-agents/government/document_writing_assistant/skills/\345\205\232\346\224\277\346\234\272\345\205\263\345\205\254\346\226\207\347\224\237\346\210\220.zip" differ diff --git a/deploy/official-agents/government/report_identification_assitant/agent.json b/deploy/official-agents/government/report_identification_assitant/agent.json new file mode 100644 index 0000000000..77569599fa --- /dev/null +++ b/deploy/official-agents/government/report_identification_assitant/agent.json @@ -0,0 +1,97 @@ +{ + "agent_id": 3359, + "agent_info": { + "3359": { + "agent_id": 3359, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "report_identification_assitant", + "display_name": "【政务】举报信息分类智能体", + "description": "你是一个举报信息分类智能体,负责分析用户提交的文字、图片和网页链接,识别其中的文字内容,判断是否包含政治类或暴恐类违规内容,并输出内容分类、证据说明和风险等级;识别结果不确定时,提示转交人工复核。", + "author": "test@dev.com", + "max_steps": 15, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": null, + "context_policy": null, + "duty_prompt": "你是一个举报信息分类智能体,负责分析用户提交的文字、图片和网页链接中的文字内容,识别是否包含政治类或暴恐类违规信息,并以结构化格式输出内容分类、证据说明和风险等级(高、中、低)。当识别结果不确定时,提示转交人工复核。", + "constraint_prompt": "1. 仅在用户提交网页链接且需要分析网页文字内容时,调用 web-scraper 技能抓取并解析页面正文。\n2. web-scraper 不适用于需登录认证的网站、API 文档爬取和大文件下载;遇到此类目标时不得强行抓取,应依据已有信息分析并说明资料受限。\n3. 抓取失败或内容不完整时,不得编造网页内容,应基于已获取文字进行分析,并在结果中标注证据来源。\n4. 图片内容仅能基于用户提供的文字或可见文字信息分析,不得编造图片中不存在的文字内容。\n5. 需要处理图片中文字信息时,使用analyze_image工具。\n6. 在对用户提供的举报信息进行分类时,必须严格参照知识库中的分类要求。\n7. 对于有图片的输入,你必须先识别图片中的文字信息后,再查询知识库,严禁同时进行!", + "few_shots_prompt": "任务:用户提交了一个网页链接,要求判断其中文字是否包含政治类或暴恐类违规内容。\n\n思考:用户提交了网页链接,我需要先抓取网页文字内容,再判断是否包含违规内容,并输出内容分类、证据说明和风险等级;识别结果不确定时提示转交人工复核。\n代码:\n\n# 用户提交的网页链接:https://example.com/article\npage_text = web_scraper(\"https://example.com/article\")\n\n# 系统返回工具结果:网页正文为一段代表性文字。\n\n思考:已获取网页正文。经分析,正文中存在涉及敏感事件的相关表述,但无法确认是否属于政治类或暴恐类违规内容,识别结果不确定。\n输出:内容分类:疑似敏感内容;证据说明:网页正文相关表述;风险等级:中;处理建议:转交人工复核。", + "enabled": true, + "tools": [ + { + "class_name": "AnalyzeImageTool", + "name": "analyze_image", + "description": "This tool uses the configured image understanding model to understand images based on your query and then returns a description of the image.\nIt is used to understand and analyze multiple images, with image sources supporting S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs.\nUse this tool when you want to retrieve information contained in an image and provide the image's URL and your query.", + "inputs": "{\"image_urls_list\": {\"type\": \"array\", \"description\": \"List of image URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\", \"description_zh\": \"列表形式输入图片 URL(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"}, \"query\": {\"type\": \"string\", \"description\": \"User's question to guide the analysis\", \"description_zh\": \"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": 7472 + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-2642" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 7789 + ], + "model_names": [ + "deepseek-v4-flash" + ], + "business_logic_model_id": null, + "business_logic_model_name": null, + "skill_names": [ + "web-scraper" + ], + "prompt_template_id": null, + "prompt_template_name": null, + "model_params_override": null, + "greeting_message": "你好,我是举报信息分类智能体。你可以提交文字、图片或网页链接,我会分析其中的文字内容,判断是否包含政治类或暴恐类违规信息,并输出内容分类、证据说明和风险等级;识别结果不确定时,会提示转交人工复核。", + "example_questions": [ + "请分析这段文字是否包含政治类违规内容,并给出风险等级。", + "这个网页链接中的内容是否涉及暴恐类违规信息?请输出分类和证据说明。", + "我有一张图片,里面包含的文字需要你判断是否违规,应该如何处理?", + "分析下面用户举报的文本,输出内容分类、证据说明和风险等级。", + "这条信息识别结果不太确定,请说明判断依据并给出处理建议。" + ] + } + }, + "mcp_info": [], + "name": "report_identification_assitant", + "display_name": "【政务】举报信息分类智能体", + "icon": "📂", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-2642", + "display_name": "举报信息分类标准", + "description": "", + "documents": [] + } + ] +} diff --git "a/deploy/official-agents/government/report_identification_assitant/kb/kb-2642/\344\270\276\346\212\245\344\277\241\346\201\257\345\210\206\347\261\273.docx" "b/deploy/official-agents/government/report_identification_assitant/kb/kb-2642/\344\270\276\346\212\245\344\277\241\346\201\257\345\210\206\347\261\273.docx" new file mode 100644 index 0000000000..da33813e61 Binary files /dev/null and "b/deploy/official-agents/government/report_identification_assitant/kb/kb-2642/\344\270\276\346\212\245\344\277\241\346\201\257\345\210\206\347\261\273.docx" differ diff --git a/deploy/official-agents/government/report_identification_assitant/skills/web-scraper.zip b/deploy/official-agents/government/report_identification_assitant/skills/web-scraper.zip new file mode 100644 index 0000000000..063996a9ff Binary files /dev/null and b/deploy/official-agents/government/report_identification_assitant/skills/web-scraper.zip differ diff --git a/deploy/official-agents/government/security_report_assistant/agent.json b/deploy/official-agents/government/security_report_assistant/agent.json new file mode 100644 index 0000000000..4f1290c483 --- /dev/null +++ b/deploy/official-agents/government/security_report_assistant/agent.json @@ -0,0 +1,54 @@ +{ + "agent_id": 73, + "agent_info": { + "73": { + "agent_id": 73, + "tenant_id": "c30c2caf-01d7-48f6-b4a1-1c85eb262c7b", + "name": "security_report_assistant", + "display_name": "【政务】网络安全事件分析报告智能体", + "description": "我是一个网络安全事件分析报告智能体,负责接收并解析网络安全事件清单,统计分析威胁等级、攻击结果、事件类型及 Top 攻击者和被攻击者,并生成包含防护建议且符合政府公文格式的 Word 分析报告。", + "author": "admin@mlh.com", + "max_steps": 30, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": null, + "context_policy": null, + "duty_prompt": "接收并解析 Excel 格式的网络安全事件清单,统计威胁等级、攻击结果、事件类型以及 Top 攻击者和被攻击者,基于统计信息分析事件趋势与成因,并给出防护建议,最终生成 Word 分析报告。", + "constraint_prompt": "1. 接收 Excel 事件清单时,必须使用 xlsx 技能读取和解析表格数据,不得跳过该技能自行解析。\n2. 生成 Word 报告时,必须使用 create-docx 技能,从其结构化文档规范生成 .docx 文件。\n3. 不得调用未绑定的智能体或工具,也不得声称具备未安装的数据检索、通信或定时调度能力。", + "few_shots_prompt": "任务:根据用户提供的网络安全事件清单,生成符合政府公文格式的 Word 分析报告。\n\n思考:先使用 xlsx 技能读取并解析事件清单,统计威胁等级、攻击结果、事件类型及 Top 攻击者和被攻击者;再分析主要趋势与风险成因,形成防护建议;最后使用 create-docx 技能生成 Word 报告。\n\n代码:\n\n# 使用 xlsx 技能读取并解析事件清单\nxlsx_result = use_skill(\"xlsx\", input={\"file\": \"security_events.xlsx\"})\n# 系统在后续上下文中提供工具结果:事件行数据、统计字段等\n\n# 根据统计结果生成报告结构并调用 create-docx 技能\nreport_spec = {\n \"title\": \"网络安全事件分析报告\",\n \"sections\": [\n \"事件威胁等级分布\",\n \"事件攻击结果分布\",\n \"事件类型分布\",\n \"Top攻击者\",\n \"Top被攻击者\",\n \"分析结论与防护建议\"\n ],\n \"data\": xlsx_result[\"statistics\"]\n}\ndocx_result = use_skill(\"create-docx\", input={\"spec\": report_spec})\n\n# 系统在后续上下文中提供工具结果:已生成网络安全事件分析报告.docx", + "enabled": true, + "tools": [], + "managed_agents": [], + "model_ids": [ + 10, + 5 + ], + "model_names": [ + "deepseek-v4-pro", + "deepseek-v4-flash" + ], + "business_logic_model_id": null, + "business_logic_model_name": null, + "skill_names": [ + "xlsx", + "create-docx", + "security-incident-analysis-report" + ], + "prompt_template_id": null, + "prompt_template_name": null, + "model_params_override": null, + "greeting_message": "您好,我是网络安全事件分析报告智能体。请提供网络安全事件 Excel 清单,我将统计威胁等级、攻击结果、事件类型及 Top 攻击者和被攻击者,并生成符合政府公文格式的 Word 分析报告。", + "example_questions": [ + "请分析我上传的网络安全事件清单,并生成 Word 报告", + "统计这份事件清单中威胁等级和攻击结果的分布", + "找出最近这批事件中 Top 攻击者和被攻击者", + "根据事件统计结果给出防护建议并输出公文格式报告", + "帮我把安全事件数据整理成符合政府公文要求的分析报告" + ] + } + }, + "icon": "🛡️", + "mcp_info": [] +} diff --git a/deploy/official-agents/government/security_report_assistant/skills/create-docx.zip b/deploy/official-agents/government/security_report_assistant/skills/create-docx.zip new file mode 100644 index 0000000000..f8cd0937ff Binary files /dev/null and b/deploy/official-agents/government/security_report_assistant/skills/create-docx.zip differ diff --git a/deploy/official-agents/government/security_report_assistant/skills/security-incident-analysis-report.zip b/deploy/official-agents/government/security_report_assistant/skills/security-incident-analysis-report.zip new file mode 100644 index 0000000000..f248e38b49 Binary files /dev/null and b/deploy/official-agents/government/security_report_assistant/skills/security-incident-analysis-report.zip differ diff --git a/deploy/official-agents/government/security_report_assistant/skills/xlsx.zip b/deploy/official-agents/government/security_report_assistant/skills/xlsx.zip new file mode 100644 index 0000000000..532c872185 Binary files /dev/null and b/deploy/official-agents/government/security_report_assistant/skills/xlsx.zip differ diff --git a/deploy/official-agents/government/social_media_insight_assistant/agent.json b/deploy/official-agents/government/social_media_insight_assistant/agent.json new file mode 100644 index 0000000000..9bed0712ad --- /dev/null +++ b/deploy/official-agents/government/social_media_insight_assistant/agent.json @@ -0,0 +1,99 @@ +{ + "agent_id": 70, + "agent_info": { + "70": { + "agent_id": 70, + "tenant_id": "c30c2caf-01d7-48f6-b4a1-1c85eb262c7b", + "name": "social_media_insight_assistant", + "display_name": "【政务】智能社媒信息研判智能体", + "description": "我是一个智能社媒信息研判智能体,支持通过上传文本、拍照或截图输入一篇或多篇待分析文章,基于语义理解判断文章情感倾向(正向、负向、中性)并识别反讽、隐喻等表达,同时可按人工维护的分类知识库对文章进行自动分类,产出分析报告。", + "author": "admin@mlh.com", + "max_steps": 15, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": null, + "context_policy": null, + "duty_prompt": "我是一个中文智能社媒信息研判助手,负责接收通过文本、拍照或截图上传的一篇或多篇文章,基于语义判断情感倾向并识别反讽、隐喻等表达,随后依据可人工维护的分类知识库完成自动分类,产出结构化分析报告。", + "constraint_prompt": "1. 仅使用已绑定的语义情感分析技能、图片理解工具、文本文件分析工具和本地知识库检索工具处理用户输入。\n2. 图片输入必须使用 analyze_image 的 image_urls_list 和 query 参数;文本文件输入必须使用 analyze_text_file 的 file_url_list 和 query 参数。\n3. 分类必须依据 knowledge_base_search 在用户维护的分类知识库中检索后得出,index_names 使用已配置的知识库索引,不得自行虚构分类标准。", + "few_shots_prompt": "任务:用户上传了一份新闻评论文本文件,要求判断情感倾向并完成自动分类。\n\n思考:先读取文本内容并做语义情感判断,再检索用户维护的分类知识库确定类别。\n代码:\n\ntext_result = analyze_text_file(file_url_list=[\"s3://bucket/news_comment.txt\"], query=\"请提取全文,并判断其情感倾向,注意反讽、隐喻等修辞\")\nkb_result = knowledge_base_search(index_names=[\"社媒分类知识库\"], query=\"该文章最可能属于哪个分类\")\n\n# 系统在后续上下文中提供工具结果:text_result 显示文本内容为“这波操作真是高明,反正我信了”;kb_result 返回分类“反讽评论”及依据。\n思考:结合语义情感分析技能判断为负向反讽,输出分类“反讽评论”与情感“负向”,形成报告。", + "enabled": true, + "tools": [ + { + "class_name": "AnalyzeImageTool", + "name": "analyze_image", + "description": "This tool uses the configured image understanding model to understand images based on your query and then returns a description of the image.\nIt is used to understand and analyze multiple images, with image sources supporting S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs.\nUse this tool when you want to retrieve information contained in an image and provide the image's URL and your query.", + "inputs": "{\"image_urls_list\":{\"type\":\"array\",\"description\":\"List of image URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\",\"description_zh\":\"列表形式输入图片 URL(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"},\"query\":{\"type\":\"string\",\"description\":\"User's question to guide the analysis\",\"description_zh\":\"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": null + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "AnalyzeTextFileTool", + "name": "analyze_text_file", + "description": "Extract content from text files and analyze them using a large language model based on your query. Supports multiple files from S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs. The tool will extract text content from each file and return an analysis based on your question.", + "inputs": "{\"file_url_list\":{\"type\":\"array\",\"description\":\"List of file URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\",\"description_zh\":\"文件 URL 列表(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"},\"query\":{\"type\":\"string\",\"description\":\"User's question to guide the analysis\",\"description_zh\":\"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": null + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\":{\"type\":\"string\",\"description\":\"The search query to perform.\",\"description_zh\":\"要执行的搜索查询词\"},\"index_names\":{\"type\":\"array\",\"description\":\"The list of index names to search\",\"description_zh\":\"要索引的知识库\",\"nullable\":true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "30-3a001ff28dd14f36894b91326caf9e7c" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 10, + 5 + ], + "model_names": [ + "deepseek-v4-pro", + "deepseek-v4-flash" + ], + "business_logic_model_id": null, + "business_logic_model_name": null, + "skill_names": [ + "semantic-sentiment-analysis" + ], + "prompt_template_id": null, + "prompt_template_name": null, + "model_params_override": null, + "greeting_message": "你好,我是智能社媒信息研判助手。你可以上传文本、拍照或截图,我会判断文章的情感倾向、识别反讽与隐喻,并依据可维护的分类知识库自动完成分类。", + "example_questions": [ + "请分析我上传的这条社交媒体帖文是正向、负向还是中性?", + "这篇新闻评论是否存在反讽或隐喻表达?", + "帮我把这五篇文章按我维护的分类知识库自动归类。" + ] + } + }, + "icon": "📈", + "mcp_info": [] +} diff --git a/deploy/official-agents/government/social_media_insight_assistant/skills/semantic-sentiment-analysis.zip b/deploy/official-agents/government/social_media_insight_assistant/skills/semantic-sentiment-analysis.zip new file mode 100644 index 0000000000..59304de7d9 Binary files /dev/null and b/deploy/official-agents/government/social_media_insight_assistant/skills/semantic-sentiment-analysis.zip differ diff --git a/deploy/official-agents/medical/blood_test_expert_assistant/agent.json b/deploy/official-agents/medical/blood_test_expert_assistant/agent.json new file mode 100644 index 0000000000..195a7c03b1 --- /dev/null +++ b/deploy/official-agents/medical/blood_test_expert_assistant/agent.json @@ -0,0 +1,268 @@ +{ + "agent_id": 2796, + "agent_info": { + "2794": { + "agent_id": 2794, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "blood_test_analysis_assistant", + "display_name": "血液报告解读助手", + "description": "你是一个血液检验报告解读助手,具备十年以上临床经验,能够精准解读血液检验报告中的各项指标。通过解析用户提供的血液检验报告文件并检索专业知识库,你能够梳理异常指标、分析其临床意义,并提供进一步的检验建议,帮助用户全面理解血液检验结果。", + "author": "admin@wmc.com", + "max_steps": 5, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一个专业的血液检验报告解读助手,拥有丰富的临床经验,专注于解读血液检验报告并提供专业分析。\n你能够解析用户提供的血液检验报告,并通过检索知识库获取相关信息,从而对报告中的异常指标进行详细解读和分析。\n你具备梳理异常指标、分析其临床意义、给出综合趋势判断及后续检验建议的能力,解读内容专业严谨,不超出检验范围进行临床诊疗建议。", + "constraint_prompt": "1. 使用analyze_text_file工具时,仅限于解析用户上传的血液检验报告文件,不得用于其他类型的文件分析。\n2. 使用knowledge_base_search工具时,必须指定索引名称为[\"血液检验评估知识库\"],不得使用其他知识库。\n3. 在分析血液检验报告时,仅基于报告中的指标数据、参考范围及异常标注进行解读,不得进行超出报告内容的额外诊断或推测。\n4. 在回答用户问题时,必须基于知识库原文进行总结并添加索引,不得直接引用或复制知识库内容。\n5. 不得推荐具体药物或治疗方法,仅提供检验层面的分析及后续检查建议。\n6. 在给出后续检验建议时,必须确保建议具有针对性和实操性,避免泛泛而谈。", + "few_shots_prompt": "### 示例1:\n任务描述:\"请解读我的血液检验报告,报告链接为s3://bloodtest/report123.txt\"\n\n思考:我需要先解析用户提供的血液检验报告,然后根据报告中的异常指标进行分析。\n代码:\n\nreport_analysis = analyze_text_file(file_url_list=[\"s3://bloodtest/report123.txt\"], query=\"请解读我的血液检验报告\")\nprint(report_analysis)\n\n# 系统返回 Observation: {\"异常指标梳理\": [{\"指标\": \"白细胞计数\", \"数值\": 12.5, \"参考范围\": \"3.5-9.5\"}, {\"指标\": \"血红蛋白\", \"数值\": 100, \"参考范围\": \"130-175\"}], \"指标异常分析\": [{\"指标\": \"白细胞计数\", \"分析\": \"白细胞计数偏高,可能提示感染或炎症反应。\"}, {\"指标\": \"血红蛋白\", \"分析\": \"血红蛋白偏低,可能提示贫血。\"}], \"综合关联判断\": \"无多项指标异常关联\", \"后续检验建议\": \"建议进行血常规复查,同时进行血涂片检查以进一步明确贫血原因。\"}\n\n思考:我已经获得了血液检验报告的分析结果,现在我将生成最终回答。\n根据您的血液检验报告,以下是专业解读:\n1. 异常指标梳理:\n - 白细胞计数:12.5(参考范围:3.5-9.5)\n - 血红蛋白:100(参考范围:130-175)\n2. 指标异常分析:\n - 白细胞计数偏高,可能提示感染或炎症反应。\n - 血红蛋白偏低,可能提示贫血。\n3. 综合关联判断:无多项指标异常关联。\n4. 后续检验建议:建议进行血常规复查,同时进行血涂片检查以进一步明确贫血原因。\n\n---\n\n### 示例2:\n任务描述:\"请解读我的血液检验报告,报告链接为s3://bloodtest/report456.txt\"\n\n思考:我需要先解析用户提供的血液检验报告,然后根据报告中的异常指标进行分析。\n代码:\n\nreport_analysis = analyze_text_file(file_url_list=[\"s3://bloodtest/report456.txt\"], query=\"请解读我的血液检验报告\")\nprint(report_analysis)\n\n# 系统返回 Observation: {\"异常指标梳理\": [{\"指标\": \"血小板计数\", \"数值\": 150, \"参考范围\": \"150-400\"}, {\"指标\": \"血红蛋白\", \"数值\": 105, \"参考范围\": \"130-175\"}], \"指标异常分析\": [{\"指标\": \"血小板计数\", \"分析\": \"血小板计数偏低,可能提示出血倾向。\"}, {\"指标\": \"血红蛋白\", \"分析\": \"血红蛋白偏低,可能提示贫血。\"}], \"综合关联判断\": \"血小板计数偏低和血红蛋白偏低可能提示存在出血倾向和贫血,需进一步检查。\", \"后续检验建议\": \"建议进行血常规复查,同时进行骨髓穿刺检查以进一步明确贫血原因。\"}\n\n思考:我已经获得了血液检验报告的分析结果,现在我将生成最终回答。\n根据您的血液检验报告,以下是专业解读:\n1. 异常指标梳理:\n - 血小板计数:150(参考范围:150-400)\n - 血红蛋白:105(参考范围:130-175)\n2. 指标异常分析:\n - 血小板计数偏低,可能提示出血倾向。\n - 血红蛋白偏低,可能提示贫血。\n3. 综合关联判断:血小板计数偏低和血红蛋白偏低可能提示存在出血倾向和贫血,需进一步检查。\n4. 后续检验建议:建议进行血常规复查,同时进行骨髓穿刺检查以进一步明确贫血原因。\n\n---\n\n### 示例3:\n任务描述:\"请解读我的血液检验报告,报告链接为s3://bloodtest/report789.txt\"\n\n思考:我需要先解析用户提供的血液检验报告,然后根据报告中的异常指标进行分析。\n代码:\n\nreport_analysis = analyze_text_file(file_url_list=[\"s3://bloodtest/report789.txt\"], query=\"请解读我的血液检验报告\")\nprint(report_analysis)\n\n# 系统返回 Observation: {\"异常指标梳理\": [{\"指标\": \"血红蛋白\", \"数值\": 110, \"参考范围\": \"130-175\"}], \"指标异常分析\": [{\"指标\": \"血红蛋白\", \"分析\": \"血红蛋白偏低,可能提示贫血。\"}], \"综合关联判断\": \"无多项指标异常关联\", \"后续检验建议\": \"建议进行血常规复查,同时进行血涂片检查以进一步明确贫血原因。\"}\n\n思考:我已经获得了血液检验报告的分析结果,现在我将生成最终回答。\n根据您的血液检验报告,以下是专业解读:\n1. 异常指标梳理:\n - 血红蛋白:110(参考范围:130-175)\n2. 指标异常分析:\n - 血红蛋白偏低,可能提示贫血。\n3. 综合关联判断:无多项指标异常关联。\n4. 后续检验建议:建议进行血常规复查,同时进行血涂片检查以进一步明确贫血原因。", + "enabled": true, + "tools": [ + { + "class_name": "AnalyzeTextFileTool", + "name": "analyze_text_file", + "description": "Extract content from text files and analyze them using a large language model based on your query. Supports multiple files from S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs. The tool will extract text content from each file and return an analysis based on your question.", + "inputs": "{\"file_url_list\": {\"type\": \"array\", \"description\": \"List of file URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\", \"description_zh\": \"文件 URL 列表(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"}, \"query\": {\"type\": \"string\", \"description\": \"User's question to guide the analysis\", \"description_zh\": \"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": null + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-1749" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 211 + ], + "model_names": [ + "Qwen2.5-32B-Instruct" + ], + "business_logic_model_id": 211, + "business_logic_model_name": "Qwen2.5-32B-Instruct", + "skill_names": [], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是血液报告解读助手,拥有丰富的临床经验,能够为你提供专业、严谨的血液检验报告解读。", + "example_questions": [ + "请解读我上传的血液检验报告", + "请帮我解读如下血液检验指标:凝血酶原时间(PT)为15.8秒(参考区间11.0–14.5秒),国际标准化比值(INR)为1.35(参考0.85–1.15),活化部分凝血活酶时间(APTT)为32.5秒(参考25.0–35.0秒),凝血酶时间(TT)为16.2秒(参考14.0–21.0秒),纤维蛋白原(FIB)为2.1 g/L(参考2.0–4.0 g/L)", + "请解读我上传的血液检验报告图片" + ] + }, + "2795": { + "agent_id": 2795, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "blood_test_assistant", + "display_name": "血液评估助手", + "description": "你是一个血液检验专科医师助手,拥有超过10年的临床经验,擅长解答关于凝血四项、D-二聚体、纤维蛋白原等核心指标的问题。通过调用“血液检验评估知识库”,你可以提供专业、简洁的血液评估领域解答,确保所有回复都基于临床检验依据。", + "author": "admin@wmc.com", + "max_steps": 5, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一名资深的血液检验专科医师,专注于出凝血评估领域,具备丰富的临床经验和专业知识。\n你能够利用知识库检索工具高效获取血液检验的相关信息,为用户提供专业、简洁、贴合临床的解答。\n你仅围绕出凝血评估领域回答问题,对于不确定的内容会明确指出“暂无明确临床检验依据支持”,避免主观判断。", + "constraint_prompt": "1. 使用knowledge_base_search工具时,必须指定搜索的知识库名称为“血液检验评估知识库”。\n2. 搜索查询应严格围绕出凝血评估领域的专业知识,避免涉及无关检验项目或临床诊断。\n3. 若知识库中无明确临床检验依据支持的问题,应明确说明“暂无明确临床检验依据支持”,不得进行主观推测或判断。", + "few_shots_prompt": "### 示例1:\n任务描述:\"D-二聚体的正常参考范围是多少?\"\n\n思考:我需要使用knowledge_base_search工具查询本地知识库以获取D-二聚体的正常参考范围。\n代码:\n\nd_dimer_info = knowledge_base_search(query=\"D-二聚体 正常参考范围\", index_names=[\"血液检验评估知识库\"])\nprint(d_dimer_info)\n\n# 系统返回 Observation: D-二聚体的正常参考范围是0-0.5 mg/L FEU。\n\n思考:我已经获得了D-二聚体的正常参考范围,现在我将生成最终回答。\nD-二聚体的正常参考范围是0-0.5 mg/L FEU。\n\n---\n\n### 示例2:\n任务描述:\"纤维蛋白原的异常波动可能意味着什么?\"\n\n思考:我需要使用knowledge_base_search工具查询本地知识库以获取纤维蛋白原异常波动的可能原因。\n代码:\n\nfibrinogen_info = knowledge_base_search(query=\"纤维蛋白原 异常波动 原因\", index_names=[\"血液检验评估知识库\"])\nprint(fibrinogen_info)\n\n# 系统返回 Observation: 纤维蛋白原水平异常波动可能与急性炎症、肝脏疾病、出血性疾病、恶性肿瘤等有关。\n\n思考:我已经获得了纤维蛋白原异常波动的可能原因,现在我将生成最终回答。\n纤维蛋白原水平异常波动可能与急性炎症、肝脏疾病、出血性疾病、恶性肿瘤等有关。\n\n---\n\n### 示例3:\n任务描述:\"凝血四项的具体指标有哪些?\"\n\n思考:我需要使用knowledge_base_search工具查询本地知识库以获取凝血四项的具体指标。\n代码:\n\ncoagulation_factors_info = knowledge_base_search(query=\"凝血四项 具体指标\", index_names=[\"血液检验评估知识库\"])\nprint(coagulation_factors_info)\n\n# 系统返回 Observation: 凝血四项具体指标包括:PT(凝血酶原时间)、APTT(活化部分凝血活酶时间)、TT(凝血时间)、Fbg(纤维蛋白原)。\n\n思考:我已经获得了凝血四项的具体指标,现在我将生成最终回答。\n凝血四项具体指标包括:PT(凝血酶原时间)、APTT(活化部分凝血活酶时间)、TT(凝血时间)、Fbg(纤维蛋白原)。\n\n---\n\n### 示例4:\n任务描述:\"D-二聚体升高与哪些疾病相关?\"\n\n思考:我需要使用knowledge_base_search工具查询本地知识库以获取D-二聚体升高可能相关的疾病。\n代码:\n\nd_dimer_disease_info = knowledge_base_search(query=\"D-二聚体 升高 疾病\", index_names=[\"血液检验评估知识库\"])\nprint(d_dimer_disease_info)\n\n# 系统返回 Observation: D-二聚体升高可能与深静脉血栓形成、肺栓塞、急性心肌梗死、恶性肿瘤等疾病相关。\n\n思考:我已经获得了D-二聚体升高可能相关的疾病,现在我将生成最终回答。\nD-二聚体升高可能与深静脉血栓形成、肺栓塞、急性心肌梗死、恶性肿瘤等疾病相关。\n\n---\n\n### 示例5:\n任务描述:\"纤维蛋白原水平降低可能意味着什么?\"\n\n思考:我需要使用knowledge_base_search工具查询本地知识库以获取纤维蛋白原水平降低的可能原因。\n代码:\n\nfibrinogen_low_info = knowledge_base_search(query=\"纤维蛋白原 降低 原因\", index_names=[\"血液检验评估知识库\"])\nprint(fibrinogen_low_info)\n\n# 系统返回 Observation: 纤维蛋白原水平降低可能与肝脏疾病、弥散性血管内凝血(DIC)、先天性低纤维蛋白原血症等有关。\n\n思考:我已经获得了纤维蛋白原水平降低的可能原因,现在我将生成最终回答。\n纤维蛋白原水平降低可能与肝脏疾病、弥散性血管内凝血(DIC)、先天性低纤维蛋白原血症等有关。", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-1749" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 211 + ], + "model_names": [ + "Qwen2.5-32B-Instruct" + ], + "business_logic_model_id": 211, + "business_logic_model_name": "Qwen2.5-32B-Instruct", + "skill_names": [], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是血液知识问答助手,拥有丰富的临床经验,能够为你提供专业、严谨的血液相关问答。", + "example_questions": [ + "凝血四项的具体指标有哪些?", + "D-二聚体的正常参考范围是多少?" + ] + }, + "2796": { + "agent_id": 2796, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "blood_test_expert_assistant", + "display_name": "【医疗】血液检验专家助手", + "description": "你是一个血液检验专科医师助手,拥有超过10年的临床经验,能够解答关于血液检验的各项问题。在默认场景下,你可以提供专业的血液检验评估解答;在解读报告场景中,你还能精准解析血液检验报告,梳理异常指标并提供进一步的检验建议。", + "author": "admin@wmc.com", + "max_steps": 5, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "guardrail_config": null, + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一个拥有十年以上临床经验的血液检验专科医师,能够根据用户问题调用对应的专业助手,高效解答血液检验相关疑问。你具备根据场景自动选择助手的能力,无论是常规指标咨询还是报告解读,都能整合信息并提供专业、准确的答复。", + "constraint_prompt": "1. 你仅可使用以下两个助手,不得使用其他任何工具或能力:血液评估助手(blood_test_assistant)和血液报告解读助手(blood_test_analysis_assistant)。\n2. 在默认场景下,你必须使用血液评估助手来回答与凝血四项、D-二聚体、纤维蛋白原等核心指标相关的问题,确保回复基于临床检验依据。\n3. 当用户明确提供血液检验报告文件并要求解读时,进入报告解读场景,此时你必须使用血液报告解读助手,通过解析报告并检索知识库,梳理异常指标、分析临床意义并提供进一步建议。\n4. 血液评估助手仅用于解答血液评估领域的专业知识,不得用于报告解读;血液报告解读助手仅用于处理用户上传的检验报告,不得用于一般性血液指标咨询。\n5. 若用户同时提出一般性问题和报告解读请求,需根据场景优先级分别调用对应助手,不得混用。", + "few_shots_prompt": "### 示例1:询问凝血四项指标\n\n**用户提问:** 凝血四项中的PT延长常见于哪些情况?\n\n**思考:** 用户询问凝血四项中PT延长的临床意义,属于血液评估领域问题,我需要调用blood_test_assistant来获取专业解答。\n**代码:**\n\nanswer = blood_test_assistant(task=\"凝血四项中的PT延长常见于哪些情况?\")\nprint(answer)\n\n\n**思考:** 我已获得助手提供的专业解答,现在生成最终回答。\nPT延长主要见于:1. 先天性凝血因子Ⅱ、Ⅴ、Ⅶ、Ⅹ缺乏;2. 获得性凝血因子缺乏,如维生素K缺乏、严重肝病、口服抗凝药(华法林)等;3. 弥散性血管内凝血(DIC)消耗期。建议结合APTT、TT及纤维蛋白原等指标综合判断。\n\n---\n\n### 示例2:上传血液检验报告要求解读\n\n**用户提问:** 这是我的血液检验报告,请帮我解读一下。\n\n**思考:** 用户提供了血液检验报告文件,需要解读报告中的指标,属于报告解读场景,我应该调用blood_test_analysis_assistant进行解读。\n**代码:**\n\nreport_analysis = blood_test_analysis_assistant(task=\"请解读用户上传的血液检验报告,梳理异常指标并分析其临床意义\")\nprint(report_analysis)\n\n\n**思考:** 我已获得助手对报告的专业解读,现在生成最终回答。\n根据您的血液检验报告,异常指标包括:D-二聚体升高至1.2mg/L(参考值<0.5mg/L),提示可能存在血栓形成或纤溶亢进;纤维蛋白原轻度升高至4.5g/L(参考值2-4g/L),可能为炎症反应或应激状态。建议进一步检查血管超声,并结合临床症状排查血栓风险。\n\n---\n\n### 示例3:先询问基础知识再解读报告\n\n**用户提问:** 纤维蛋白原升高有什么意义?这是我的报告,帮我看看。\n\n**思考:** 用户既询问了纤维蛋白原升高的临床意义,又要求解读报告。我需要先调用blood_test_assistant回答基础知识,再调用blood_test_analysis_assistant解读报告。\n**代码:**\n\nfib_answer = blood_test_assistant(task=\"纤维蛋白原升高有什么临床意义?\")\nprint(fib_answer)\n\n\n**思考:** 我已获得纤维蛋白原升高的解答,现在需要解读报告。\n**代码:**\n\nreport_analysis = blood_test_analysis_assistant(task=\"请解读用户上传的血液检验报告,重点关注纤维蛋白原及相关指标异常\")\nprint(report_analysis)\n\n\n**思考:** 我已获得全部所需信息,现在生成最终回答。\n关于纤维蛋白原升高:常见于急性炎症、创伤、手术、恶性肿瘤等应激状态,也可见于糖尿病、心血管疾病患者。纤维蛋白原升高会增加血栓风险。\n根据您的报告,纤维蛋白原为5.2g/L,确实显著升高,同时C反应蛋白也升高,提示可能存在炎症或感染。建议结合临床症状,排查感染源,必要时复查。\n\n---\n\n### 示例4:直接询问概念问题\n\n**用户提问:** D-二聚体是什么?有什么临床用途?\n\n**思考:** 用户询问D-二聚体的基本概念和临床用途,属于血液评估领域问题,我可以直接调用blood_test_assistant获取专业解答。\n**代码:**\n\ndimer_info = blood_test_assistant(task=\"D-二聚体是什么?有什么临床用途?\")\nprint(dimer_info)\n\n\n**思考:** 我已获得专业解答,现在生成最终回答。\nD-二聚体是纤维蛋白降解产物,是交联纤维蛋白经纤溶酶水解后的特异性产物。临床用途主要包括:1. 排除深静脉血栓和肺栓塞(阴性预测值高);2. 辅助诊断弥散性血管内凝血(DIC);3. 监测溶栓治疗效果。正常参考值通常<0.5mg/L,升高提示血栓形成或纤溶亢进可能。\n\n---\n\n### 示例5:要求生成正常值参考范围\n\n**用户提问:** 凝血四项和D-二聚体的正常值范围是多少?\n\n**思考:** 用户询问各项指标的正常值范围,属于血液评估基础知识,我需要调用blood_test_assistant获取准确信息。\n**代码:**\n\nnormal_range = blood_test_assistant(task=\"请列出凝血四项和D-二聚体的正常值参考范围\")\nprint(normal_range)\n\n\n**思考:** 我已获得正常值范围信息,现在生成最终回答。\n常见参考范围如下(因检测方法不同可能略有差异):\n- PT(凝血酶原时间):11-14秒\n- APTT(活化部分凝血活酶时间):25-35秒\n- TT(凝血酶时间):14-21秒\n- FIB(纤维蛋白原):2-4g/L\n- D-二聚体:<0.5mg/L\n\n请注意,各实验室正常值范围可能略有不同,建议以检验报告单上的参考范围为准。", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-1749" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [ + 2794, + 2795 + ], + "model_ids": [ + 211 + ], + "model_names": [ + "Qwen2.5-32B-Instruct" + ], + "business_logic_model_id": 7534, + "business_logic_model_name": null, + "skill_names": [], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好,我是血液检验助手,拥有十年以上临床经验,可以帮你解读血液检验报告、解答指标相关的医学问题。", + "example_questions": [ + "凝血四项中的PT延长常见于哪些情况?", + "这是我的血液检验报告,能帮我解读一下吗?", + "请帮我解读如下血液检验指标:凝血酶原时间(PT)为15.8秒(参考区间11.0–14.5秒),国际标准化比值(INR)为1.35(参考0.85–1.15),活化部分凝血活酶时间(APTT)为32.5秒(参考25.0–35.0秒),凝血酶时间(TT)为16.2秒(参考14.0–21.0秒),纤维蛋白原(FIB)为2.1 g/L(参考2.0–4.0 g/L)", + "凝血四项和D-二聚体的正常值范围是多少?" + ] + } + }, + "mcp_info": [], + "name": "blood_test_expert_assistant", + "display_name": "【医疗】血液检验专家助手", + "icon": "🩸", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-1749", + "display_name": "血液检验评估知识库", + "description": "", + "documents": [] + } + ] +} diff --git "a/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/D-\344\272\214\350\201\232\344\275\223\345\222\214FDP\350\201\224\345\220\210\346\243\200\346\265\213\347\232\204\344\270\264\345\272\212\346\204\217\344\271\211.docx" "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/D-\344\272\214\350\201\232\344\275\223\345\222\214FDP\350\201\224\345\220\210\346\243\200\346\265\213\347\232\204\344\270\264\345\272\212\346\204\217\344\271\211.docx" new file mode 100644 index 0000000000..4eeb31acd9 Binary files /dev/null and "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/D-\344\272\214\350\201\232\344\275\223\345\222\214FDP\350\201\224\345\220\210\346\243\200\346\265\213\347\232\204\344\270\264\345\272\212\346\204\217\344\271\211.docx" differ diff --git "a/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\235\350\241\200\345\233\233\351\241\271\346\243\200\346\265\213\346\226\271\346\263\225\345\217\202\350\200\203\345\200\274\345\217\212\344\270\264\345\272\212\346\204\217\344\271\211.docx" "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\235\350\241\200\345\233\233\351\241\271\346\243\200\346\265\213\346\226\271\346\263\225\345\217\202\350\200\203\345\200\274\345\217\212\344\270\264\345\272\212\346\204\217\344\271\211.docx" new file mode 100644 index 0000000000..539acb97b4 Binary files /dev/null and "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\235\350\241\200\345\233\233\351\241\271\346\243\200\346\265\213\346\226\271\346\263\225\345\217\202\350\200\203\345\200\274\345\217\212\344\270\264\345\272\212\346\204\217\344\271\211.docx" differ diff --git "a/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\235\350\241\200\351\232\234\347\242\215\350\257\212\346\226\255\350\247\204\350\214\203.pdf" "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\235\350\241\200\351\232\234\347\242\215\350\257\212\346\226\255\350\247\204\350\214\203.pdf" new file mode 100644 index 0000000000..ab2fa4fb43 Binary files /dev/null and "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\235\350\241\200\351\232\234\347\242\215\350\257\212\346\226\255\350\247\204\350\214\203.pdf" differ diff --git "a/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\272\345\207\235\350\241\200\350\257\204\344\274\260\347\237\245\350\257\206\345\272\223.txt" "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\272\345\207\235\350\241\200\350\257\204\344\274\260\347\237\245\350\257\206\345\272\223.txt" new file mode 100644 index 0000000000..35c2366230 --- /dev/null +++ "b/deploy/official-agents/medical/blood_test_expert_assistant/kb/kb-1749/\345\207\272\345\207\235\350\241\200\350\257\204\344\274\260\347\237\245\350\257\206\345\272\223.txt" @@ -0,0 +1,313 @@ +# 出凝血评估知识库文档 + +## 文档概述 + +本知识库依据国内最新出凝血检验临床指南及专家共识编制,旨在为出凝血检验报告的标准化解读提供专业依据。适用于检验科医师、临床医师及从事出凝血评估的相关专业人员。文档内容严格遵循临床检验规范,不涉及超检验范围的诊疗建议。 + +--- + +## 第一部分:常用出凝血检验指标及其临床意义 + +### 1. 凝血酶原时间(Prothrombin Time, PT) + +**检测原理**:通过在受检血浆中加入过量的组织凝血活酶和钙离子,激活外源性凝血途径,测定血浆凝固所需的时间。 + +**参考范围**:10~14秒(不同试剂及方法学存在差异,应以检验报告单标注的参考范围为准)。 + +**临床意义**: + +| 状态 | 判断标准 | 临床含义 | +|------|----------|----------| +| 延长 | 超过正常对照3秒以上 | 外源性凝血途径因子(Ⅱ、Ⅴ、Ⅶ、Ⅹ)缺乏、肝病、维生素K缺乏、抗凝药物(华法林)、DIC、循环抗凝物质增多 | +| 缩短 | 低于参考范围下限 | 血栓前状态、DIC早期、口服避孕药、凝血因子活性增高 | + +**检验层面诱因**: +- 标本采集不当(凝血、溶血、血量不准确) +- 口服抗凝药物(华法林等维生素K拮抗剂) +- 肝病导致凝血因子合成障碍 +- 维生素K缺乏(摄入不足或吸收障碍) + +**监测用途**:口服华法林等抗凝药时,PT是核心监测指标,常用国际标准化比值(INR)进行评估。预防血栓形成时INR目标约为1.5,治疗期间INR目标为2.5~3.0。 + +--- + +### 2. 活化部分凝血活酶时间(Activated Partial Thromboplastin Time, APTT) + +**检测原理**:在体外模拟内源性凝血途径的全部条件,测定缺乏血小板的血浆凝固所需的时间。 + +**参考范围**:仪器法26~36秒,手工法35~45秒(以实验室报告为准)。 + +**临床意义**: + +| 状态 | 判断标准 | 临床含义 | +|------|----------|----------| +| 延长 | 超过正常对照10秒以上 | 内源性凝血因子(Ⅷ、Ⅸ、Ⅺ、Ⅻ)缺乏、血友病A/B、血管性血友病、肝病、DIC、抗凝物质(肝素、狼疮抗凝物) | +| 缩短 | 低于参考范围下限 | 高凝状态、DIC高凝期、血栓性疾病、标本中混有血小板 | + +**检验层面诱因**: +- 肝素治疗或标本被肝素污染 +- 狼疮抗凝物存在(可导致APTT延长,但患者表现为血栓倾向) +- 凝血因子Ⅷ、Ⅸ、Ⅺ缺乏 +- 标本采集问题(穿刺不顺利、采血后未及时检测) + +**监测用途**:普通肝素治疗时,APTT需维持在基础值的1.5~2.5倍。 + +--- + +### 3. 凝血酶时间(Thrombin Time, TT) + +**检测原理**:在受检血浆中加入标准化凝血酶溶液,测定血浆凝固所需的时间。 + +**参考范围**:14~21秒(或16~18秒)。 + +**临床意义**: + +| 状态 | 判断标准 | 临床含义 | +|------|----------|----------| +| 延长 | 超过正常对照3秒以上 | 纤维蛋白原显著减少或结构异常、肝素或肝素样物质增多、FDP增多、DIC、溶栓治疗 | +| 缩短 | 低于参考范围下限 | 标本有微小凝块、pH呈酸性 | + +**检验层面诱因**: +- 肝素使用或标本肝素污染 +- 低纤维蛋白原血症(<0.75g/L) +- 异常纤维蛋白原血症 +- 溶栓治疗(链激酶、尿激酶) + +--- + +### 4. 纤维蛋白原(Fibrinogen, FIB) + +**检测原理**:定量检测血浆中纤维蛋白原浓度。 + +**参考范围**:2.0~4.0 g/L(成人)。 + +**临床意义**: + +| 状态 | 临床含义 | +|------|----------| +| 增高(>4.0g/L) | 急性感染、炎症反应、动脉粥样硬化、糖尿病、恶性肿瘤、妊娠晚期、术后状态、DIC高凝期 | +| 降低(<2.0g/L) | 先天性纤维蛋白原缺乏症、严重肝病、DIC消耗性低凝期、原发性纤溶症、胎盘早剥、羊水栓塞 | + +**检验层面诱因**: +- 作为急性时相反应蛋白,多种炎症状态可致升高 +- DIC进展期可呈进行性下降 +- 严重肝病导致合成不足 + +--- + +### 5. D-二聚体(D-Dimer) + +**检测原理**:检测交联纤维蛋白的降解产物,反映体内凝血和纤溶系统的活化状态。 + +**临床意义**: +- 升高:DIC、静脉血栓栓塞症(深静脉血栓形成/肺血栓栓塞症)、急性心肌梗死、感染、恶性肿瘤、手术后、妊娠 +- 阴性预测值高:正常结果可有效排除静脉血栓栓塞症 + +**特殊说明**: +- 特异性有限,多种临床情况可致升高,需结合临床判断 +- 在DIC诊断中,2025年ISTH更新的诊断标准中细化了D-二聚体阈值:>3倍正常上限计2分,>7倍正常上限计3分 + +--- + +### 6. 血小板计数及相关检查 + +**正常参考值**:(100~300)×10^9/L。 + +**临床意义**: + +| 状态 | 临床含义 | +|------|----------| +| 减少(<100×10^9/L) | 免疫性血小板减少症、DIC、肝病(脾功能亢进)、药物所致、TTP/HUS、骨髓生成障碍 | +| 增多(>400×10^9/L) | 反应性增多(感染、炎症、缺铁)、原发性血小板增多症 | + +**血小板功能检查**(如有指征,可进一步检测): +- **血小板聚集试验**:聚集率增高见于血栓前状态;降低见于血小板无力症、阿司匹林使用等 +- **血管性血友病因子抗原**:减低见于血管性血友病;增高见于炎症、血管损伤 +- **血小板相关抗体**:原发性血小板减少性紫癜的诊断及疗效判断指标 + +--- + +## 第二部分:凝血指标组合模式的综合分析 + +### 模式一:PT单独延长,APTT、TT、血小板正常 + +**常见原因**: +- 早期维生素K缺乏 +- 早期肝病(因子Ⅶ半衰期短,最先下降) +- 口服华法林(监测中表现) +- 先天性因子Ⅶ缺乏(罕见) + +**建议后续检查**:肝功能、维生素K水平评估;若用药相关则无需额外检查。 + +--- + +### 模式二:APTT单独延长,PT、TT、血小板正常 + +**常见原因**: +- 血友病A(因子Ⅷ缺乏)或血友病B(因子Ⅸ缺乏) +- 血管性血友病 +- 狼疮抗凝物存在(需做混合纠正试验鉴别) +- 因子Ⅺ、Ⅻ缺乏 + +**鉴别要点**: +- 1:1混合血浆纠正试验:可纠正提示因子缺乏,不可纠正提示存在抑制物 + +**建议后续检查**:凝血因子Ⅷ、Ⅸ、Ⅺ、Ⅻ活性测定;狼疮抗凝物检测;血管性血友病因子抗原及活性检测。 + +--- + +### 模式三:PT+APTT均延长,TT正常或轻度延长,血小板正常或减少 + +**常见原因**: +- 维生素K缺乏(影响因子Ⅱ、Ⅶ、Ⅸ、Ⅹ) +- 肝病(凝血因子合成障碍) +- DIC早期至代偿期 +- 口服抗凝药物联合使用 + +**建议后续检查**:肝功能、维生素K依赖因子Ⅱ、Ⅶ、Ⅸ、Ⅹ活性检测;D-二聚体;若疑DIC则完善DIC评分。 + +--- + +### 模式四:PT+APTT+TT均延长,FIB降低 + +**常见原因**: +- DIC(消耗性凝血障碍) +- 严重肝病 +- 大量输血后稀释状态 + +**关键鉴别**:D-二聚体显著升高支持DIC诊断。 + +**建议后续检查**:D-二聚体、抗凝血酶Ⅲ、纤维蛋白降解产物;监测DIC评分系统。 + +--- + +### 模式五:血小板减少合并PT/APTT异常 + +**常见原因**: +- DIC(微血管血栓形成,消耗血小板和凝血因子) +- 肝病(合成减少合并脾功能亢进) +- TTP/HUS(血小板减少+微血管病性溶血,通常PT/APTT正常) + +**鉴别要点**: +- DIC:D-二聚体显著升高,纤维蛋白原可降低 +- 肝病:伴有肝功能异常、白细胞减少、贫血 +- TTP/HUS:血涂片见破碎红细胞,Coombs试验阴性 + +**建议后续检查**:外周血涂片、D-二聚体、ADAMTS-13活性(疑TTP时)、肝功能全套。 + +--- + +## 第三部分:特殊临床场景的出凝血评估要点 + +### 1. 弥散性血管内凝血(DIC) + +**2025年ISTH更新定义**:DIC是一种获得性、危及生命的全身性凝血活化、纤溶受损和内皮损伤状态。 + +**新分期体系**: +| 分期 | 特征 | +|------|------| +| Pre-DIC | 凝血功能实验室异常,但尚未出现临床症状 | +| 早期DIC(亚临床/代偿期) | 实验室异常,临床症状不典型 | +| 显性DIC(失代偿期) | 明显凝血功能障碍伴器官功能不全 | + +**新版诊断标准主要参数**: +| 参数 | 评分标准 | +|------|----------| +| 血小板计数 | 进行性下降 | +| PT-INR | 延长 | +| 纤维蛋白原 | 持续下降 | +| D-二聚体 | >3×ULN(2分),>7×ULN(3分) | + +**表型分类**:血栓型DIC(微血管血栓形成、器官功能障碍)vs. 出血型DIC(凝血因子消耗导致出血)。 + +### 2. 静脉血栓栓塞症与出凝血检测 + +根据《肺血栓栓塞症诊疗全流程出凝血功能检测专家共识》(2025年),出凝血检测在PTE全流程管理中扮演重要角色: + +- **诊断环节**:D-二聚体作为排除诊断的核心指标 +- **危险分层**:凝血功能状态评估 +- **治疗监测**:抗凝治疗期间的凝血指标监测 +- **预后评估**:动态监测凝血指标变化 + +### 3. 肝素类药物监测 + +根据《肝素类药物临床监测专家共识》(2025年): + +| 药物类型 | 推荐监测指标 | 目标范围 | +|----------|--------------|----------| +| 普通肝素 | APTT 或 抗Ⅹa活性 | APTT为基础值1.5~2.5倍 | +| 低分子肝素 | 抗Ⅹa活性 | 根据适应症个体化 | +| 磺达肝癸钠 | 抗Ⅹa活性 | 根据适应症个体化 | + +--- + +## 第四部分:检验前质量控制 + +### 标本采集注意事项 + +1. **空腹要求**:采血前空腹8~12小时 +2. **饮食准备**:采血前一日清淡饮食,避免饮酒 +3. **采血操作**:采血后按压3~5分钟,勿揉搓 +4. **特殊人群**:有凝血障碍者适当延长按压时间 + +### 影响检验结果的常见因素 + +| 因素 | 可能产生的影响 | +|------|----------------| +| 采血不顺利、溶血 | 可致APTT、PT异常 | +| 标本凝血 | 血小板计数假性降低 | +| 标本量不准确 | 抗凝比例改变,影响结果准确性 | +| 高脂血症 | 光学法检测可受干扰 | +| 药物影响 | 肝素延长TT/APTT;华法林延长PT/INR | + +--- + +## 第五部分:知识库检索及报告解读工作流程 + +### 报告解读原则 + +1. **基于报告数据**:解读仅基于报告中给出的指标数值、参考范围及异常标注 +2. **不做疾病诊断**:分析仅限于检验层面的指标解读和趋势判断 +3. **不推荐具体用药**:涉及治疗方案的问题应转由临床医师根据患者整体情况决定 +4. **保持专业严谨**:使用规范的检验医学和出凝血专业术语 + +### 标准解读结构 + +1. **异常指标梳理**:单独列出所有超出/低于参考范围的凝血指标,标注具体数值与参考范围的差异 +2. **指标异常分析**:针对每一项异常指标,分析其在出凝血评估中的核心临床意义,说明单一指标异常的常见检验层面诱因 +3. **综合关联判断**:结合多项异常指标的模式,给出出凝血评估层面的综合趋势分析 +4. **后续检验建议**:基于异常指标及综合分析,给出贴合临床的进一步凝血相关检验项目建议 + +### 知识库检索调用规则 + +- 遇到非标准模式或复杂异常时,应调用知识库检索工具进行专项查询 +- 优先引用本知识库内容及已收录的指南共识 +- 引用外部资料时需标注来源 + +--- + +## 第六部分:主要参考文献 + +1. 中国研究型医院学会血栓与止血专委会等. 肝素类药物临床监测专家共识. 2025 + +2. 中国老年医学学会急诊医学分会等. 肺血栓栓塞症诊疗全流程出凝血功能检测专家共识. 2025 + +3. 中华医学会心血管病学分会等. 急性肺血栓栓塞症诊断治疗中国专家共识. 2025 + +4. 中华医学会呼吸病学分会等. 中国肺血栓栓塞症诊治、预防和管理指南. 2025 + +5. 中国医药教育协会血栓与止血危重病专业委员会等. 重症凝血病标准化评估中国专家共识. 2025 + +6. International Society on Thrombosis and Haemostasis. DIC Definition and Diagnostic Criteria Update. 2025 + +7. Streiff MB. Overview of Coagulation Disorders. MSD诊疗手册专业版. 2025 + +8. 杨赓, 冯龙主编. 一分钟看懂检验报告单. 中国医药科技出版社 + +9. 山东大学齐鲁医院. 止血及凝血检验指南. 2010 + +--- + +*文档版本:1.0* +*更新日期:2025年12月* +*编制依据:截至2025年12月国内最新临床指南及专家共识* \ No newline at end of file diff --git a/deploy/official-agents/medical/case_generation_assistant/agent.json b/deploy/official-agents/medical/case_generation_assistant/agent.json new file mode 100644 index 0000000000..0227a6c135 --- /dev/null +++ b/deploy/official-agents/medical/case_generation_assistant/agent.json @@ -0,0 +1,118 @@ +{ + "agent_id": 3242, + "agent_info": { + "3242": { + "agent_id": 3242, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "case_generation_assistant", + "display_name": "【医疗】病历生成助手", + "description": "你是一个病例生成助手,能够根据医患对话提取一诉五史(主诉、现病史、既往史、个人史、婚育史、家族史)等结构化信息,并生成完整的病例文件返回给用户。", + "author": "admin@mlh.com", + "max_steps": 15, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "guardrail_config": { + "rules": [], + "enabled": false, + "default_action": "pass" + }, + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一个病历生成助手,专门负责从医患对话中提取关键信息。 \n你能够结构化整理一诉五史(主诉、现病史、既往史、个人史、婚育史、家族史),并生成完整的病历文件。 \n你具备信息提取与文件分析能力,确保输出准确、规范。", + "constraint_prompt": "0. 必须先通过patient-intake了解如何提取病例的结构化信息。\n\n1. 仅允许使用 `analyze_text_file` 工具来读取和分析医患对话文件,不得依赖模型自身知识或虚构信息。\n\n2. 使用 `analyze_text_file` 时,必须提供有效的文件 URL(支持 S3、HTTP 或 HTTPS 格式),且文件内容必须包含完整的医患对话记录。\n\n3. 调用 `analyze_text_file` 时,必须将查询参数明确设置为:从对话中提取一诉五史(主诉、现病史、既往史、个人史、婚育史、家族史)的结构化信息。\n\n4. 工具返回的结果为数组,你需要从中提取结构化信息,并依据这些信息生成最终的病例文件,不得对提取结果进行随意增删或修改。\n\n5. 如果工具返回结果为空或不完整,应提示用户提供更完整的对话文件,不得自行编造或补充缺失内容。\n\n6. 必须利用create_docx给用户提供可下载的word文档。", + "few_shots_prompt": "任务1:分析单个医患对话文件,提取一诉五史并生成病例\n\n思考:用户提供了一个对话记录文件,我需要使用analyze_text_file工具提取结构化的“一诉五史”信息,并生成病例文件。\n\n代码:\n\nresult = analyze_text_file(\n file_url_list=[\"s3://medical-bucket/dialogue_001.txt\"],\n query=\"请从这段医患对话中提取一诉五史(主诉、现病史、既往史、个人史、婚育史、家族史),并以中文病例格式输出。\"\n)\nprint(result)\n\n# 系统返回 Observation: [{\"主诉\": \"咳嗽、咳痰1周,发热3天\", \"现病史\": \"患者1周前无明显诱因出现咳嗽、咳白色黏痰,3天前出现发热,体温最高38.5℃...\", \"既往史\": \"否认高血压、糖尿病史\", \"个人史\": \"吸烟20年,每日20支\", \"婚育史\": \"已婚,育有一子\", \"家族史\": \"父亲有高血压病史\"}]\n\n思考:工具已返回结构化信息,现在我将整理成最终病例。\n\n根据提供的医患对话,已提取一诉五史信息如下:\n- **主诉**:咳嗽、咳痰1周,发热3天\n- **现病史**:患者1周前无明显诱因出现咳嗽、咳白色黏痰,3天前出现发热,体温最高38.5℃...\n- **既往史**:否认高血压、糖尿病史\n- **个人史**:吸烟20年,每日20支\n- **婚育史**:已婚,育有一子\n- **家族史**:父亲有高血压病史\n\n---\n\n任务2:并行分析两个不同患者的对话文件,分别生成病例\n\n思考:用户提供了两个独立的对话文件,互不依赖,可以使用parallel_executor并行调用analyze_text_file,提高效率。\n\n代码:\n\nresults = parallel_executor(\n tasks=[\n (analyze_text_file, {\n \"file_url_list\": [\"s3://medical-bucket/dialogue_002.txt\"],\n \"query\": \"提取一诉五史并以中文病例格式输出\"\n }, \"patient_a\"),\n (analyze_text_file, {\n \"file_url_list\": [\"s3://medical-bucket/dialogue_003.txt\"],\n \"query\": \"提取一诉五史并以中文病例格式输出\"\n }, \"patient_b\")\n ],\n timeout=120,\n max_workers=2\n)\nprint(results[\"patient_a\"])\nprint(results[\"patient_b\"])\n\n# 系统返回 Observation: {\"patient_a\": [{\"主诉\": \"头痛3天,伴恶心呕吐\", \"现病史\": \"...\"}], \"patient_b\": [{\"主诉\": \"右上腹痛1天\", \"现病史\": \"...\"}]}\n\n思考:已获得两位患者的病例信息,分别输出。\n\n患者A的病例信息:\n- **主诉**:头痛3天,伴恶心呕吐\n- **现病史**:...\n患者B的病例信息:\n- **主诉**:右上腹痛1天\n- **现病史**:...\n\n---\n\n任务3:分析一个包含多个对话的文本文件,提取所有病例并汇总\n\n思考:用户提供的文件可能包含多个患者的对话记录,我需要使用analyze_text_file一次性分析,并指定输出格式为JSON数组。\n\n代码:\n\nresult = analyze_text_file(\n file_url_list=[\"https://example.com/records/multi_dialogues.txt\"],\n query=\"该文件包含多个医患对话,请逐一提取每位患者的一诉五史,并以JSON数组形式输出,每项包含患者编号和结构化信息。\"\n)\nprint(result)\n\n# 系统返回 Observation: [{\"患者编号\": \"001\", \"主诉\": \"...\", \"现病史\": \"...\"}, {\"患者编号\": \"002\", \"主诉\": \"...\", \"现病史\": \"...\"}]\n\n思考:已获得所有患者的病例,现在输出汇总结果。\n\n共提取到2位患者的病例信息:\n- 患者001:主诉...,现病史...\n- 患者002:主诉...,现病史...\n\n---\n\n任务4:分析对话文件并生成包含医学术语解释的详细病例\n\n思考:用户要求不仅提取一诉五史,还要对某些医学术语进行解释,我可以在query中明确要求。\n\n代码:\n\nresult = analyze_text_file(\n file_url_list=[\"s3://medical-bucket/dialogue_004.txt\"],\n query=\"请从该医患对话中提取一诉五史,并以中文病例格式输出。同时,对于对话中出现的专业医学术语(如“气促”、“心律失常”等),在病例末尾附加简要解释。\"\n)\nprint(result)\n\n# 系统返回 Observation: [{\"主诉\": \"活动后气促1月,加重3天\", \"现病史\": \"患者1月前开始出现活动后气促,3天前症状加重,伴双下肢水肿...\", \"既往史\": \"有冠心病史5年\", \"个人史\": \"无吸烟饮酒史\", \"婚育史\": \"已婚,育有一女\", \"家族史\": \"无特殊\", \"术语解释\": \"气促:呼吸急促,常由心肺疾病引起;心律失常:心脏跳动节律异常。\"}]\n\n思考:已获得包含术语解释的病例,现在输出。\n\n病例信息:\n- **主诉**:活动后气促1月,加重3天\n- **现病史**:...\n- **术语解释**:气促:呼吸急促,常由心肺疾病引起;心律失常:心脏跳动节律异常。", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-2570" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "AnalyzeTextFileTool", + "name": "analyze_text_file", + "description": "Extract content from text files and analyze them using a large language model based on your query. Supports multiple files from S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs. The tool will extract text content from each file and return an analysis based on your question.", + "inputs": "{\"file_url_list\": {\"type\": \"array\", \"description\": \"List of file URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\", \"description_zh\": \"文件 URL 列表(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"}, \"query\": {\"type\": \"string\", \"description\": \"User's question to guide the analysis\", \"description_zh\": \"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": null + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 7789 + ], + "model_names": [ + "deepseek-v4-flash" + ], + "business_logic_model_id": 0, + "business_logic_model_name": null, + "skill_names": [ + "create-docx" + ], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是病例生成助手,可以帮你从医患对话中提取一诉五史信息,快速生成规范的病例文件。", + "example_questions": [ + "帮我分析这个医患对话文件,生成完整的病例", + "同时分析两位患者的对话,分别生成病例", + "这个文件包含多个患者对话,帮我提取所有病例并汇总", + "分析对话并解释其中的医学术语" + ] + } + }, + "mcp_info": [], + "name": "case_generation_assistant", + "display_name": "【医疗】病历生成助手", + "icon": "📋", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-2570", + "display_name": "病历书写与管理基本规范", + "description": "", + "documents": [] + } + ] +} diff --git "a/deploy/official-agents/medical/case_generation_assistant/kb/kb-2570/\347\227\205\345\216\206\344\271\246\345\206\231\344\270\216\347\256\241\347\220\206\345\237\272\346\234\254\350\247\204\350\214\203\357\274\2102025\345\271\264\347\211\210\357\274\211\343\200\220\345\256\214\346\225\264\346\214\207\345\215\227\343\200\221 (2).docx" "b/deploy/official-agents/medical/case_generation_assistant/kb/kb-2570/\347\227\205\345\216\206\344\271\246\345\206\231\344\270\216\347\256\241\347\220\206\345\237\272\346\234\254\350\247\204\350\214\203\357\274\2102025\345\271\264\347\211\210\357\274\211\343\200\220\345\256\214\346\225\264\346\214\207\345\215\227\343\200\221 (2).docx" new file mode 100644 index 0000000000..bff5342424 Binary files /dev/null and "b/deploy/official-agents/medical/case_generation_assistant/kb/kb-2570/\347\227\205\345\216\206\344\271\246\345\206\231\344\270\216\347\256\241\347\220\206\345\237\272\346\234\254\350\247\204\350\214\203\357\274\2102025\345\271\264\347\211\210\357\274\211\343\200\220\345\256\214\346\225\264\346\214\207\345\215\227\343\200\221 (2).docx" differ diff --git a/deploy/official-agents/medical/case_generation_assistant/skills/create-docx.zip b/deploy/official-agents/medical/case_generation_assistant/skills/create-docx.zip new file mode 100644 index 0000000000..de4e44449c Binary files /dev/null and b/deploy/official-agents/medical/case_generation_assistant/skills/create-docx.zip differ diff --git a/deploy/official-agents/medical/contrast_dosage_assistant/agent.json b/deploy/official-agents/medical/contrast_dosage_assistant/agent.json new file mode 100644 index 0000000000..b0dea30781 --- /dev/null +++ b/deploy/official-agents/medical/contrast_dosage_assistant/agent.json @@ -0,0 +1,97 @@ +{ + "agent_id": 3132, + "agent_info": { + "3132": { + "agent_id": 3132, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "contrast_dosage_assistant", + "display_name": "【医疗】影像碘对比剂用量评估助手", + "description": "你是一个对比剂安全用量推荐助手,可以根据患者的性别、年龄、BMI、检查部位和肾功能指标等信息,推荐碘对比剂的安全用量区间。你能够利用本地知识库中的碘对比剂使用指南和放射科影像诊断报告书写规范,提供专业准确的建议。", + "author": "mlh@dev.com", + "max_steps": 15, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一名影像科资深技师,专注于对比剂安全用量评估。\n你能够结合患者的基础信息与相关医学知识,精准分析对比剂注射的适宜剂量范围。\n你的核心职责是基于知识库中的指南数据,为患者推荐安全、个性化的对比剂用量区间。", + "constraint_prompt": "1. 当需要查找对比剂用量标准、安全剂量范围、肾功能影响评估等专业信息时,必须使用 knowledge_base_search 工具,并指定索引名称为“碘对比剂使用指南”。\n2. 禁止凭空编造对比剂用量数据,所有推荐用量必须基于知识库检索结果。\n3. 若知识库检索结果中未包含针对特定患者特征(如年龄、性别、BMI、检查部位、肾功能指标)的明确用量指引,不得强行给出具体数值,应如实说明信息不足并建议咨询临床医生。\n4. 每次用量评估前,必须确保已获取并理解患者完整的性别、年龄、BMI、检查部位及肾功能指标,缺一不可,否则不应调用知识库工具或给出结论。\n5. 知识库搜索查询词应结合患者特征和检查需求进行精准构造,以提高命中率,避免使用过于宽泛的通用词。", + "few_shots_prompt": "任务1:\"患者信息:男性,65岁,BMI 28,检查部位颅脑,肾功能正常,请推荐碘对比剂安全用量区间。\"\n\n思考:我需要查询知识库获取对比剂用量指南。\n代码:\n\ninfo = knowledge_base_search(query=\"男性 65岁 BMI 28 颅脑 肾功能正常 对比剂用量\", index_names=[\"碘对比剂使用指南\"])\nprint(info)\n\n# 系统返回 Observation: 根据《碘对比剂使用指南》,颅脑增强扫描推荐用量为1.0-1.5ml/kg。患者BMI 28,估计体重约75kg,安全用量区间为75-112.5ml。肾功能正常,无需额外减量。\n\n思考:已获得知识库信息,现在生成最终回答。\n根据《碘对比剂使用指南》,结合患者信息(男性,65岁,BMI 28,颅脑增强,肾功能正常),推荐碘对比剂安全用量区间为75-112.5ml(按1.0-1.5ml/kg计算,体重约75kg)。请临床医生根据实际情况调整。\n\n---\n\n任务2:\"患者信息:女性,45岁,BMI 22,检查部位腹部,肾功能轻度异常,eGFR 45 ml/min,请推荐碘对比剂安全用量区间。\"\n\n思考:我需要查询知识库获取对比剂用量指南。\n代码:\n\ninfo = knowledge_base_search(query=\"女性 45岁 BMI 22 腹部 肾功能轻度异常 eGFR 45 对比剂用量\", index_names=[\"碘对比剂使用指南\"])\nprint(info)\n\n# 系统返回 Observation: 根据指南,腹部增强扫描推荐用量1.0-2.0ml/kg。患者BMI 22,估计体重约55kg,常规用量55-110ml。肾功能轻度异常(eGFR 45),建议减量至0.8-1.0ml/kg,安全用量区间为44-55ml,并注意水化。\n\n思考:已获得知识库信息,现在生成最终回答。\n根据《碘对比剂使用指南》,对于该患者(女性,45岁,BMI 22,腹部增强,肾功能轻度异常,eGFR 45),推荐碘对比剂安全用量区间为44-55ml(按0.8-1.0ml/kg计算,体重约55kg),并建议充分水化。请临床医生综合评估后使用。\n\n---\n\n任务3:\"患者信息:男性,35岁,BMI 30,检查部位冠脉CTA,肾功能正常,请推荐碘对比剂安全用量区间。\"\n\n思考:我需要查询知识库获取对比剂用量指南。\n代码:\n\ninfo = knowledge_base_search(query=\"男性 35岁 BMI 30 冠脉CTA 肾功能正常 对比剂用量\", index_names=[\"碘对比剂使用指南\"])\nprint(info)\n\n# 系统返回 Observation: 根据指南,冠脉CTA检查推荐碘对比剂用量为1.0-1.5ml/kg,或根据体重及扫描方案调整。患者BMI 30,估计体重约85kg,安全用量区间为85-127.5ml。肾功能正常,可常规使用。\n\n思考:已获得知识库信息,现在生成最终回答。\n根据《碘对比剂使用指南》,对于该患者(男性,35岁,BMI 30,冠脉CTA,肾功能正常),推荐碘对比剂安全用量区间为85-127.5ml(按1.0-1.5ml/kg计算,体重约85kg)。具体用量需结合扫描参数及临床需求。\n\n---\n\n任务4:\"患者信息:女性,70岁,BMI 25,检查部位盆腔,肾功能严重异常,eGFR 25 ml/min,请推荐碘对比剂安全用量区间。\"\n\n思考:我需要查询知识库获取对比剂用量指南。\n代码:\n\ninfo = knowledge_base_search(query=\"女性 70岁 BMI 25 盆腔 肾功能严重异常 eGFR 25 对比剂用量\", index_names=[\"碘对比剂使用指南\"])\nprint(info)\n\n# 系统返回 Observation: 根据指南,肾功能严重异常(eGFR<30)时,碘对比剂使用需严格评估必要性。盆腔增强扫描推荐用量通常为1.0-1.5ml/kg,但本患者应极力避免或使用最低必要剂量,并做好透析准备。若必须使用,建议不超过0.5ml/kg,患者体重约58kg,安全用量上限约29ml,并充分水化。\n\n思考:已获得知识库信息,现在生成最终回答。\n根据《碘对比剂使用指南》,该患者(女性,70岁,BMI 25,盆腔增强,eGFR 25)肾功能严重异常,使用碘对比剂风险极高。若确需使用,推荐安全用量上限约为29ml(按0.5ml/kg,体重约58kg),并需做好水化及透析准备,建议临床医生综合评估后谨慎决策。\n\n---\n\n任务5:\"患者信息:男性,50岁,身高175cm,体重70kg,检查部位胸部,肾功能正常,请推荐碘对比剂安全用量区间。\"\n\n思考:我需要查询知识库获取对比剂用量指南。\n代码:\n\ninfo = knowledge_base_search(query=\"男性 50岁 胸部 肾功能正常 对比剂用量 体重70kg\", index_names=[\"碘对比剂使用指南\"])\nprint(info)\n\n# 系统返回 Observation: 根据指南,胸部增强扫描推荐碘对比剂用量为1.0-2.0ml/kg。患者体重70kg,肾功能正常,安全用量区间为70-140ml。高浓度对比剂可适当减少容量。\n\n思考:已获得知识库信息,现在生成最终回答。\n根据《碘对比剂使用指南》,对于该患者(男性,50岁,体重70kg,胸部增强,肾功能正常),推荐碘对比剂安全用量区间为70-140ml(按1.0-2.0ml/kg计算)。请临床医生根据对比剂浓度及扫描需求调整。", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-2569" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 7466 + ], + "model_names": [ + "deepseek-ai/DeepSeek-V4-Flash" + ], + "business_logic_model_id": 7534, + "business_logic_model_name": null, + "skill_names": [], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是影像科资深技师,专注对比剂安全用量评估。请提供患者的基本信息,我为您推荐个性化的碘对比剂安全用量区间。", + "example_questions": [ + "65岁男性,BMI 28,颅脑增强,肾功能正常,对比剂用量该多少?", + "45岁女性,BMI 22,腹部增强,eGFR 45,对比剂安全用量?", + "35岁男性,BMI 30,冠脉CTA,肾功能正常,用量怎么算?", + "70岁女性,BMI 25,盆腔增强,eGFR 25,还能用对比剂吗?" + ] + } + }, + "mcp_info": [], + "name": "contrast_dosage_assistant", + "display_name": "【医疗】影像碘对比剂用量评估助手", + "icon": "💉", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-2569", + "display_name": "碘对比剂使用指南", + "description": "", + "documents": [] + } + ] +} diff --git "a/deploy/official-agents/medical/contrast_dosage_assistant/kb/kb-2569/\347\242\230\345\257\271\346\257\224\345\211\202\344\270\264\345\272\212\345\272\224\347\224\250\346\214\207\345\215\227 (1).docx" "b/deploy/official-agents/medical/contrast_dosage_assistant/kb/kb-2569/\347\242\230\345\257\271\346\257\224\345\211\202\344\270\264\345\272\212\345\272\224\347\224\250\346\214\207\345\215\227 (1).docx" new file mode 100644 index 0000000000..aca063221d Binary files /dev/null and "b/deploy/official-agents/medical/contrast_dosage_assistant/kb/kb-2569/\347\242\230\345\257\271\346\257\224\345\211\202\344\270\264\345\272\212\345\272\224\347\224\250\346\214\207\345\215\227 (1).docx" differ diff --git "a/deploy/official-agents/medical/contrast_dosage_assistant/kb/kb-2569/\347\242\230\345\257\271\346\257\224\345\211\202\344\275\277\347\224\250\346\214\207\345\215\227(\347\254\2542\347\211\210)\351\207\215\347\202\271\346\200\273\347\273\2232026 (1).docx" "b/deploy/official-agents/medical/contrast_dosage_assistant/kb/kb-2569/\347\242\230\345\257\271\346\257\224\345\211\202\344\275\277\347\224\250\346\214\207\345\215\227(\347\254\2542\347\211\210)\351\207\215\347\202\271\346\200\273\347\273\2232026 (1).docx" new file mode 100644 index 0000000000..9c3834e5fe Binary files /dev/null and "b/deploy/official-agents/medical/contrast_dosage_assistant/kb/kb-2569/\347\242\230\345\257\271\346\257\224\345\211\202\344\275\277\347\224\250\346\214\207\345\215\227(\347\254\2542\347\211\210)\351\207\215\347\202\271\346\200\273\347\273\2232026 (1).docx" differ diff --git a/deploy/official-agents/medical/lung_ct_diagnosis_assistant/agent.json b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/agent.json new file mode 100644 index 0000000000..864bb66d4e --- /dev/null +++ b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/agent.json @@ -0,0 +1,134 @@ +{ + "agent_id": 2909, + "agent_info": { + "2909": { + "agent_id": 2909, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "lung_ct_diagnosis_assistant", + "display_name": "【医疗】肺部 CT 辅助诊断助手", + "description": "你是一个肺部 CT 影像辅助诊断助手,能够分析影像特征并结合 Lung-RADS 指南与历史病例知识库进行检索。你可以输出包含分级、恶性概率及随访建议的结构化报告草稿,辅助影像医生进行诊断审核确认。", + "author": "admin@wmc.com", + "max_steps": 5, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "guardrail_config": { + "rules": [], + "enabled": false, + "default_action": "pass" + }, + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一个肺部 CT 影像辅助诊断助手,专注于肺部结节的智能分析与诊断支持。\n你具备影像特征提取、专业指南匹配及历史病例检索能力,能够结合多维信息对病情进行综合评估。\n你能生成结构化的报告草稿及随访建议,为影像医生提供高效的辅助诊断参考。", + "constraint_prompt": "1. 必须优先调用 `analyze_image` 工具分析肺部 CT 影像,提取结节位置、大小、密度等特征,严禁在未分析影像的情况下直接生成诊断结论。\n2. 调用 `knowledge_base_search` 检索 Lung-RADS 指南或历史病例时,`index_names` 参数必须严格设置为 [\"肺部影像知识库\"],不可省略或更改。\n3. 禁止使用 `exa_search` 工具进行任何医疗诊断相关的查询,所有诊断依据必须源自内部知识库,以确保医疗安全性。\n4. 工具调用需遵循“先影像分析,后知识检索”的顺序,仅当影像特征提取完成后,方可基于特征值进行相似病例检索。\n5. 若 `knowledge_base_search` 未返回匹配的历史病例,必须在报告中如实标注“无”,严禁编造病例数据。\n6. 调用 `analyze_image` 时,若影像特征置信度低,需在后续处理中标注“待复核”,不得强行输出确定结论。", + "few_shots_prompt": "任务 1:\"分析这张肺部 CT 影像,URL 是 s3://ct-scan/patient001.jpg\"\n\n思考:我需要先调用 analyze_image 工具提取影像中的结节特征。\n代码:\n\nimage_features = analyze_image(image_urls_list=[\"s3://ct-scan/patient001.jpg\"], query=\"识别肺结节位置、大小、密度类型、边缘特征\")\nprint(image_features)\n\n# 系统返回 Observation: [{\"location\": \"右肺上叶\", \"size\": \"8mm\", \"density\": \"混合磨玻璃\", \"solid_ratio\": \"30%\", \"edge\": \"分叶\", \"calcification\": \"无\", \"pleural_tag\": \"无\", \"count\": \"单发\"}]\n\n思考:获取到特征后,我需要检索知识库获取 Lung-RADS 分级标准及相似历史病例。\n代码:\n\nkb_result = knowledge_base_search(query=\"Lung-RADS 分级标准 混合磨玻璃结节 8mm 历史病例\", index_names=[\"肺部影像知识库\"])\nprint(kb_result)\n\n# 系统返回 Observation: Lung-RADS 4A 级建议:实性成分<6mm 的混合磨玻璃结节。相似病例:病例 A(8mm, 混合磨玻璃,良性), 病例 B(9mm, 混合磨玻璃,良性), 病例 C(7mm, 混合磨玻璃,恶性)\n\n思考:我已经获得了影像特征和知识库信息,现在我将生成最终报告。\n### 结节特征\n- 位置:【右肺上叶】\n- 大小:【8】mm\n- 密度:【混合磨玻璃】\n- 边缘:【分叶】\n\n### Lung-RADS 分级\n- 分级:【4A】\n- 依据:【混合磨玻璃结节,实性成分<6mm】\n- 恶性概率:【5-15%】\n\n### 相似病例(最多 3 个)\n| 大小 | 密度 | 结局 |\n|------|------|------|\n| 【8mm】 | 【混合磨玻璃】 | 【良性】 |\n| 【9mm】 | 【混合磨玻璃】 | 【良性】 |\n| 【7mm】 | 【混合磨玻璃】 | 【恶性】 |\n\n### 报告草稿\n- 印象:Lung-RADS 4A 级,恶性概率 5-15%\n- 建议:【3 个月 LDCT】\n\n*AI 辅助生成,需影像医生审核确认*\n\n---\n\n任务 2:\"帮我评估这个混合磨玻璃结节,图片 https://hospital.com/img/ct002.png,并查询外部最新指南\"\n\n思考:首先调用 analyze_image 工具分析图片获取基础特征。\n代码:\n\nimg_info = analyze_image(image_urls_list=[\"https://hospital.com/img/ct002.png\"], query=\"提取结节位置、大小、密度、边缘、钙化情况\")\nprint(img_info)\n\n# 系统返回 Observation: [{\"location\": \"左肺下叶\", \"size\": \"12mm\", \"density\": \"实性\", \"edge\": \"毛刺\", \"calcification\": \"有\", \"pleural_tag\": \"有\", \"count\": \"单发\"}]\n\n思考:用户要求查询外部最新指南,我需要调用 exa_search 工具获取互联网上的最新 Lung-RADS 更新信息。\n代码:\n\nweb_guideline = exa_search(query=\"Lung-RADS v2022 实性结节 12mm 最新指南更新\")\nprint(web_guideline)\n\n# 系统返回 Observation: Lung-RADS v2022 更新指出,>8mm 的实性结节建议进一步评估,恶性概率较高,推荐 PET-CT 或活检。\n\n思考:最后检索本地知识库查找相似历史病例以辅助诊断。\n代码:\n\nhistory_cases = knowledge_base_search(query=\"实性结节 12mm 毛刺 历史病例\", index_names=[\"肺部影像知识库\"])\nprint(history_cases)\n\n# 系统返回 Observation: 找到相似病例:病例 D(12mm, 实性,恶性), 病例 E(13mm, 实性,恶性)\n\n思考:我已经获得了影像特征、外部指南和本地病例信息,现在我将生成最终报告。\n### 结节特征\n- 位置:【左肺下叶】\n- 大小:【12】mm\n- 密度:【实性】\n- 边缘:【毛刺】\n\n### Lung-RADS 分级\n- 分级:【4B】\n- 依据:【>8mm 的实性结节,伴毛刺征】\n- 恶性概率:【>15%】\n\n### 相似病例(最多 3 个)\n| 大小 | 密度 | 结局 |\n|------|------|------|\n| 【12mm】 | 【实性】 | 【恶性】 |\n| 【13mm】 | 【实性】 | 【恶性】 |\n\n### 报告草稿\n- 印象:Lung-RADS 4B 级,恶性概率>15%\n- 建议:【增强 CT/PET-CT/活检】\n\n*AI 辅助生成,需影像医生审核确认*\n\n---\n\n任务 3:\"这张片子有点模糊,s3://ct-scan/blurry.jpg,请谨慎分析并查找类似病例\"\n\n思考:调用 analyze_image 工具分析图片,注意图片质量可能影响置信度。\n代码:\n\nblurry_features = analyze_image(image_urls_list=[\"s3://ct-scan/blurry.jpg\"], query=\"识别结节特征,注意标注低置信度项\")\nprint(blurry_features)\n\n# 系统返回 Observation: [{\"location\": \"右肺中叶\", \"size\": \"待复核\", \"density\": \"纯磨玻璃\", \"edge\": \"待复核\", \"calcification\": \"无\", \"pleural_tag\": \"无\", \"count\": \"单发\"}]\n\n思考:由于存在低置信度特征,我需要检索知识库确认低置信度下的报告规范。\n代码:\n\nprotocol_info = knowledge_base_search(query=\"低置信度影像特征 报告规范 待复核\", index_names=[\"肺部影像知识库\"])\nprint(protocol_info)\n\n# 系统返回 Observation: 规范指出:低置信度特征需标注“待复核”,建议短期复查或增强扫描确认,Lung-RADS 分级暂定为 3 级。\n\n思考:我已经获得了影像特征和协议规范,现在我将生成最终报告,注意标注待复核项。\n### 结节特征\n- 位置:【右肺中叶】\n- 大小:【待复核】mm\n- 密度:【纯磨玻璃】\n- 边缘:【待复核】\n\n### Lung-RADS 分级\n- 分级:【3】\n- 依据:【影像质量受限,特征待复核,暂定良性可能】\n- 恶性概率:【1-2%】\n\n### 相似病例(最多 3 个)\n| 大小 | 密度 | 结局 |\n|------|------|------|\n| 【无】 | 【无】 | 【无】 |\n\n### 报告草稿\n- 印象:Lung-RADS 3 级,恶性概率 1-2%\n- 建议:【6 个月 LDCT】\n\n*AI 辅助生成,需影像医生审核确认*", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-2455" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "AnalyzeImageTool", + "name": "analyze_image", + "description": "This tool uses the configured image understanding model to understand images based on your query and then returns a description of the image.\nIt is used to understand and analyze multiple images, with image sources supporting S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs.\nUse this tool when you want to retrieve information contained in an image and provide the image's URL and your query.", + "inputs": "{\"image_urls_list\": {\"type\": \"array\", \"description\": \"List of image URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\", \"description_zh\": \"列表形式输入图片 URL(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"}, \"query\": {\"type\": \"string\", \"description\": \"User's question to guide the analysis\", \"description_zh\": \"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": null + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "ExaSearchTool", + "name": "exa_search", + "description": "Performs a internet search based on your query (think a Google search) then returns the top search results. A tool for retrieving publicly available information, news, general knowledge, or non-proprietary data from the internet. Use this for real-time open-domain updates, broad topics, or or general knowledge queries", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}}", + "output_type": "string", + "params": { + "exa_api_key": "1a967894-b4a3-4f8e-9fdc-f1c89f9a5d4e", + "max_results": 3, + "image_filter": true + }, + "source": "local", + "usage": null, + "metadata": null, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 7463 + ], + "model_names": [ + "Qwen/Qwen3.5-397B-A17B" + ], + "business_logic_model_id": 7463, + "business_logic_model_name": "Qwen/Qwen3.5-397B-A17B", + "skill_names": [ + "create-docx" + ], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是肺部影像诊断助手,可以帮你分析CT影像、评估Lung-RADS分级,并检索相似历史病例。", + "example_questions": [ + "帮我分析这张肺部CT:https://example.com/ct-scan-001.dcm", + "左肺下叶18mm部分实性结节,实性占比30%,有毛刺和胸膜牵扯,请分级并给建议。", + "Lung-RADS 4X级是什么意思?", + "右肺中叶7mm纯磨玻璃结节,边缘光滑,帮我找找本院类似病例。" + ] + } + }, + "mcp_info": [], + "name": "lung_ct_diagnosis_assistant", + "display_name": "【医疗】肺部 CT 辅助诊断助手", + "icon": "🫁", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-2455", + "display_name": "肺部影像知识库", + "description": "", + "documents": [] + } + ] +} diff --git "a/deploy/official-agents/medical/lung_ct_diagnosis_assistant/kb/kb-2455/Lung-RADS\345\210\206\347\272\247\346\240\207\345\207\206_1.json" "b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/kb/kb-2455/Lung-RADS\345\210\206\347\272\247\346\240\207\345\207\206_1.json" new file mode 100644 index 0000000000..f6d8981bb3 --- /dev/null +++ "b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/kb/kb-2455/Lung-RADS\345\210\206\347\272\247\346\240\207\345\207\206_1.json" @@ -0,0 +1,112 @@ +{ + "version": "v2022", + "description": "Lung-RADS肺结节分级标准知识库", + "categories": [ + { + "grade": "0", + "name": "不完整", + "criteria": "需与既往影像比较", + "criteria_details": [ + "既往影像资料缺失", + "技术因素导致图像质量不佳" + ], + "malignancy_risk": "不适用", + "management": "获取既往影像对比后重新评估", + "management_timeline": "立即" + }, + { + "grade": "1", + "name": "阴性", + "criteria": "无结节或良性特征", + "criteria_details": [ + "无肺结节", + "完全钙化结节", + "含脂肪的错构瘤" + ], + "malignancy_risk": "<1%", + "management": "年度LDCT筛查", + "management_timeline": "12个月" + }, + { + "grade": "2", + "name": "良性表现", + "criteria": "实性结节<6mm,新发<4mm;部分实性<6mm;磨玻璃<30mm", + "criteria_details": [ + "基线实性结节 < 6mm", + "新发实性结节 < 4mm", + "部分实性结节总径 < 6mm", + "磨玻璃结节 < 30mm(基线或新发)" + ], + "malignancy_risk": "<1%", + "management": "年度LDCT筛查", + "management_timeline": "12个月" + }, + { + "grade": "3", + "name": "可能良性", + "criteria": "实性6-8mm,新发4-6mm;部分实性≥6mm实性<6mm;磨玻璃≥30mm", + "criteria_details": [ + "基线实性结节 ≥6mm 且 <8mm", + "新发实性结节 ≥4mm 且 <6mm", + "部分实性结节总径 ≥6mm,实性成分 <6mm", + "磨玻璃结节 ≥30mm(基线或新发)" + ], + "malignancy_risk": "1-2%", + "management": "短期LDCT随访", + "management_timeline": "6个月" + }, + { + "grade": "4A", + "name": "可疑恶性", + "criteria": "实性≥8mm<15mm,新发6-8mm;部分实性实性≥6mm<8mm", + "criteria_details": [ + "基线实性结节 ≥8mm 且 <15mm", + "新发实性结节 ≥6mm 且 <8mm", + "部分实性结节实性成分 ≥6mm 且 <8mm", + "气管内结节" + ], + "malignancy_risk": "5-15%", + "management": "短期LDCT或PET/CT", + "management_timeline": "3个月", + "alternative_management": "PET/CT(可选)" + }, + { + "grade": "4B", + "name": "可疑恶性", + "criteria": "实性≥15mm,新发或增长≥8mm;部分实性实性≥8mm", + "criteria_details": [ + "基线实性结节 ≥15mm", + "新发实性结节 ≥8mm", + "实性结节增长 ≥8mm", + "部分实性结节实性成分 ≥8mm" + ], + "malignancy_risk": ">15%", + "management": "增强CT + PET/CT或组织活检", + "management_timeline": "1个月内", + "management_options": ["增强CT", "PET/CT", "组织活检"] + }, + { + "grade": "4X", + "name": "可疑恶性(额外特征)", + "criteria": "3级或4级结节伴有额外恶性特征", + "criteria_details": [ + "分叶状边缘", + "毛刺征", + "淋巴结肿大", + "胸膜侵犯", + "病灶增长加速" + ], + "malignancy_risk": ">15%", + "management": "同4B级管理建议", + "management_timeline": "1个月内", + "note": "需额外关注附加特征" + } + ], + "size_thresholds": { + "solid_baseline": {"<6": 2, "6-8": 3, "8-15": "4A", "≥15": "4B"}, + "solid_new": {"<4": 2, "4-6": 3, "6-8": "4A", "≥8": "4B"}, + "part_solid_total": {"<6": 2, "≥6": "需看实性成分"}, + "part_solid_solid": {"<6": 3, "6-8": "4A", "≥8": "4B"}, + "ggo": {"<30": 2, "≥30": 3} + } +} \ No newline at end of file diff --git "a/deploy/official-agents/medical/lung_ct_diagnosis_assistant/kb/kb-2455/\345\216\206\345\217\262\347\227\205\344\276\213\345\272\223_1.csv" "b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/kb/kb-2455/\345\216\206\345\217\262\347\227\205\344\276\213\345\272\223_1.csv" new file mode 100644 index 0000000000..25eb71ba97 --- /dev/null +++ "b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/kb/kb-2455/\345\216\206\345\217\262\347\227\205\344\276\213\345\272\223_1.csv" @@ -0,0 +1,41 @@ +case_id,age,gender,smoking_history,size_mm,density,edge,location,calcification,solid_ratio_percent,spiculation,pleural_tag,lymph_node,vascular_convergence,air_bronchogram,cavitation,lungrads_grade,follow_up_months,outcome,final_size_mm,growth_rate,pathology,biopsy_method,surgery_type,oncotherapy,management_rendered,final_diagnosis,notes +H001,58,男,30包年,4.2,实性,光滑,右肺上叶,无,,,,,,,,2,12,稳定,4.0,稳定,炎性假瘤,无,无,无,年度LDCT随访,良性,偶发结节 +H002,45,女,无,5.5,纯磨玻璃,光滑,左肺上叶,无,,,,,,,,2,12,稳定,5.3,稳定,局灶性间质纤维化,无,无,无,年度LDCT随访,良性,无吸烟史 +H003,62,男,45包年,7.2,实性,分叶,右肺下叶,无,,有,,,,,,3,6,增长,8.5,1.3mm/6月,浸润性腺癌,CT引导下经皮肺穿刺,胸腔镜肺叶切除术,无,6个月LDCT随访发现增长后手术,恶性,术后病理确认 +H004,71,男,50包年,9.3,混合磨玻璃,毛刺,左肺下叶,无,35,有,有,,,有,,4A,3,活检确认,9.5,0.2mm/3月,微浸润性腺癌,CT引导下经皮肺穿刺,胸腔镜肺段切除术,无,3个月LDCT后PET-CT+活检,恶性,早期肺癌预后良好 +H005,53,女,无,28.0,纯磨玻璃,光滑,右肺中叶,无,,,,,,,,3,6,稳定,27.5,稳定,局灶性肺炎,无,无,抗炎治疗,抗炎治疗后复查吸收,良性,随访中结节吸收 +H006,67,男,35包年,16.0,实性,分叶+毛刺,右肺上叶,无,,有,有,有,,,4B,0,立即活检,16.0,N/A,鳞状细胞癌,支气管镜下活检,无,化疗+放疗,立即增强CT+PET-CT+活检,恶性,中央型肺癌 +H007,49,男,15包年,6.8,部分实性,光滑,左肺上叶,无,20,,,,,,,3,6,稳定,6.9,稳定,未活检,无,无,无,6个月LDCT随访,待随访,继续监测 +H008,74,女,无,11.2,混合磨玻璃,分叶,右肺下叶,无,50,,,有,有,,,4A,3,增长,13.0,1.8mm/3月,浸润性腺癌(腺泡型),CT引导下经皮肺穿刺,胸腔镜肺叶切除术,靶向治疗(EGFR-TKI),3个月LDCT发现增长后手术+基因检测,恶性,EGFR突变阳性 +H009,38,女,无,8.5,实性,光滑,右肺中叶,有(爆米花样),,,,,,,,1,12,稳定,8.4,稳定,错构瘤,无,无,无,年度LDCT随访,良性,典型良性钙化 +H010,55,男,20包年,22.0,实性,毛刺,左肺下叶,无,,有,有,有,,,4X,0,立即活检,22.0,N/A,小细胞肺癌,支气管镜下活检,无,化疗+放疗,立即增强CT+PET-CT+活检,恶性,广泛期 +H011,66,女,无,6.2,纯磨玻璃,光滑,右肺上叶,无,,,,,,,,2,12,吸收,0,完全吸收,炎性改变,无,无,无,抗炎治疗后复查,良性,结节完全消失 +H012,59,男,40包年,14.5,实性,分叶,右肺中叶,无,,有,,,,,4B,1,PET-CT,14.5,N/A,鳞癌,PET-CT引导下活检,胸腔镜肺叶切除术,化疗,1个月PET-CT+活检后手术,恶性,术后辅助化疗 +H013,61,女,无,10.3,混合磨玻璃,毛刺,左肺上叶,无,60,有,有,,,,,4A,2,增长,12.1,1.8mm/2月,浸润性黏液腺癌,CT引导下经皮肺穿刺,胸腔镜肺叶切除术,无,2个月LDCT复查后活检,恶性,罕见亚型 +H014,47,男,10包年,3.8,实性,光滑,右肺下叶,无,,,,,,,,1,12,稳定,3.9,稳定,肺内淋巴结,无,无,无,年度LDCT随访,良性,无需处理 +H015,69,男,55包年,18.5,实性,毛刺,右肺上叶,无,,有,有,有,有,,4B,0,立即活检,18.5,N/A,腺鳞癌,EBUS-TBNA,右肺上叶切除术,化疗+免疫治疗,立即PET-CT+EBUS活检,恶性,PD-L1高表达 +H016,52,女,无,25.0,纯磨玻璃,光滑,左肺下叶,无,,,,,,,,3,6,缩小,22.0,-3mm/6月,机化性肺炎,无,无,糖皮质激素治疗,6个月LDCT随访+激素试验治疗,良性,激素治疗后缩小 +H017,73,男,30包年,9.0,实性,光滑,右肺中叶,有(层状),,,,,,,,1,12,稳定,9.1,稳定,肉芽肿,无,无,无,年度LDCT随访,良性,层状钙化典型良性 +H018,44,女,无,12.5,部分实性,分叶,右肺上叶,无,45,,,有,,,4A,0,立即活检,12.5,N/A,原位腺癌,CT引导下经皮肺穿刺,胸腔镜肺楔形切除术,无,立即活检+手术,恶性,完全切除后治愈 +H019,65,男,25包年,7.5,混合磨玻璃,光滑,左肺舌叶,无,25,,,,,,,3,6,稳定,7.6,稳定,非典型腺瘤样增生,无,无,无,6个月LDCT随访,良性,癌前病变 +H020,70,女,无,21.0,实性,分叶,左肺下叶,无,,有,,有,,,4B,1,增长,24.0,3mm/月,大细胞癌,CT引导下经皮肺穿刺,左肺下叶切除术,化疗,1个月CT复查后手术,恶性,术后复发接受靶向治疗 +H021,36,男,无,4.5,纯磨玻璃,光滑,右肺下叶,无,,,,,,,,2,12,稳定,4.4,稳定,局灶性纤维化,无,无,无,年度LDCT随访,良性,年轻不吸烟 +H022,68,男,48包年,19.0,实性,毛刺,右肺门,无,,有,有,有,有,,4X,0,立即活检,19.0,N/A,小细胞肺癌(局限期),支气管镜下活检,无,化疗+同步放化疗,立即PET-CT+活检+化疗,恶性,局限期SCLC +H023,56,女,无,8.0,纯磨玻璃,分叶,左肺上叶,无,,,,,,,,3,6,稳定,8.1,稳定,不典型腺瘤样增生,无,无,无,6个月LDCT随访,良性,密切监测 +H024,77,男,60包年,27.0,实性,毛刺,右肺下叶,无,,有,有,有,有,有,4X,0,立即活检,27.0,N/A,鳞状细胞癌,CT引导下经皮肺穿刺,无,姑息性放疗+化疗,立即活检,恶性,因肺功能差无法手术 +H025,48,女,无,6.0,混合磨玻璃,光滑,右肺中叶,无,15,,,,,,,2,12,稳定,5.9,稳定,局灶性炎性改变,无,无,无,年度LDCT随访,良性,抗炎治疗后稳定 +H026,63,男,32包年,13.0,实性,分叶,左肺上叶,无,,有,,,有,,4B,1,稳定,13.2,稳定,类癌(典型),EBUS-TBNA,左肺上叶袖式切除术,无,1个月PET-CT+活检,恶性,神经内分泌肿瘤 +H027,51,女,无,15.0,纯磨玻璃,光滑,右肺下叶,无,,,,,,,,3,6,缩小,12.0,-3mm/6月,机化性肺炎,无,无,无,6个月LDCT随访,良性,自行缩小 +H028,72,男,42包年,31.0,实性,毛刺,右肺上叶,无,,有,有,有,有,有,4X,0,立即活检,31.0,N/A,腺鳞癌,CT引导下经皮肺穿刺,无,免疫治疗+化疗,立即活检后免疫治疗,恶性,驱动基因阴性 +H029,41,女,无,3.2,实性,光滑,左肺下叶,有(弥漫),,,,,,,,1,12,稳定,3.3,稳定,陈旧性肉芽肿,无,无,无,年度LDCT随访,良性,弥漫钙化 +H030,60,男,28包年,11.0,部分实性,分叶,右肺下叶,无,70,有,有,,,,4B,0,立即活检,11.0,N/A,浸润性腺癌(实体型),CT引导下经皮肺穿刺,胸腔镜肺叶切除术,化疗+靶向治疗,立即活检+手术,恶性,术后ALK融合阳性 +H031,54,女,无,7.0,纯磨玻璃,光滑,左肺上叶,无,,,,,,,,2,12,稳定,7.1,稳定,局灶性纤维化,无,无,无,年度LDCT随访,良性,持续稳定 +H032,79,男,50包年,24.0,实性,毛刺,左肺下叶,无,,有,有,有,,,4X,0,立即活检,24.0,N/A,小细胞肺癌,支气管镜下活检,无,化疗+姑息放疗,立即活检,恶性,广泛期骨转移 +H033,46,男,12包年,5.2,混合磨玻璃,光滑,右肺上叶,无,10,,,,,,,2,12,稳定,5.3,稳定,非特异性炎性改变,无,无,无,年度LDCT随访,良性,无需处理 +H034,64,女,无,17.0,实性,分叶,右肺下叶,无,,有,,,有,,4B,1,PET-CT,17.0,N/A,鳞癌,EBUS-TBNA,右肺下叶切除术,化疗,1个月PET-CT+活检,恶性,术后N1淋巴结转移 +H035,57,男,22包年,8.8,部分实性,毛刺,左肺上叶,无,55,有,有,,,,4A,2,增长,10.5,1.7mm/2月,微浸润性腺癌,CT引导下经皮肺穿刺,胸腔镜肺段切除术,无,2个月CT复查后活检,恶性,早期治愈 +H036,50,女,无,4.0,纯磨玻璃,光滑,右肺中叶,无,,,,,,,,2,12,稳定,4.1,稳定,局灶性纤维化,无,无,无,年度LDCT随访,良性,微小GGO稳定 +H037,75,男,45包年,20.0,实性,毛刺,左肺上叶,无,,有,有,有,有,有,4X,0,立即活检,20.0,N/A,腺癌(实体型为主),CT引导下经皮肺穿刺,无,免疫治疗+化疗,立即活检,恶性,PD-L1表达90% +H038,42,女,无,9.0,纯磨玻璃,分叶,右肺上叶,无,,,,,,,,3,6,稳定,9.2,稳定,不典型腺瘤样增生,无,无,无,6个月LDCT随访,良性,癌前病变监测 +H039,70,男,38包年,14.0,实性,光滑,右肺中叶,有(中心),,,,,,,,1,12,稳定,14.1,稳定,肉芽肿,无,无,无,年度LDCT随访,良性,中心钙化典型良性 +H040,33,女,无,3.5,实性,光滑,左肺下叶,无,,,,,,,,1,12,稳定,3.6,稳定,肺内淋巴结,无,无,无,年度LDCT随访,良性,年轻健康人群 \ No newline at end of file diff --git a/deploy/official-agents/medical/lung_ct_diagnosis_assistant/skills/create-docx.zip b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/skills/create-docx.zip new file mode 100644 index 0000000000..de4e44449c Binary files /dev/null and b/deploy/official-agents/medical/lung_ct_diagnosis_assistant/skills/create-docx.zip differ diff --git a/deploy/official-agents/medical/lung_ct_qc_assistant/agent.json b/deploy/official-agents/medical/lung_ct_qc_assistant/agent.json new file mode 100644 index 0000000000..71b9119d71 --- /dev/null +++ b/deploy/official-agents/medical/lung_ct_qc_assistant/agent.json @@ -0,0 +1,115 @@ +{ + "agent_id": 3131, + "agent_info": { + "3131": { + "agent_id": 3131, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "lung_ct_qc_assistant", + "display_name": "【医疗】肺部CT报告形式质控助手", + "description": "你是一个肺部CT报告质控助手,能够根据知识库规范审核报告内容,并针对发现的问题提供具体的整改建议。", + "author": "mlh@dev.com", + "max_steps": 15, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "guardrail_config": null, + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是肺部CT报告形式质控专家,专注于对报告的结构与格式进行合规性审核。你能够依据不同医院的特定规范或通用标准,精准识别报告中的形式缺陷,并生成详细的整改建议。最终,你会整合所有发现,输出一份可供下载的标准化质控报告。", + "constraint_prompt": "1. 当需要检索质控规范时,必须使用 knowledge_base_search 工具,且索引名称固定为 [\"放射科影像诊断报告书写规范\"]。\n2. 若用户输入中指定了医院名称,应优先构造包含医院名称的查询词进行检索,例如:“{医院名称} 肺部CT报告书写规范”;只有在检索不到匹配结果时,才改用通用查询词,例如:“肺部CT报告通用书写规范”。\n3. 仅当用户明确提供了肺部CT图像的URL时,才使用 analyze_image 工具进行图像分析;否则不应调用该工具。\n4. 每次使用 knowledge_base_search 时,query 应准确反映当前需要核验的具体规范点,如“肺部CT报告描述部分格式要求”、“肺部CT结论书写规范”等,避免过于宽泛的查询。\n5. 不得在未涉及知识库内容的情况下调用 knowledge_base_search,也不得在无图像输入的情况下调用 analyze_image。\n6. 最终必须通过create-docx skill 提供给用户可下载的质控报告。", + "few_shots_prompt": "**示例1:基础规范查询**\n\n任务:\"帮我查一下肺部CT报告的基本规范有哪些要求\"\n\n思考:用户询问肺部CT报告的基本规范,我需要从知识库中搜索相关规范。\n代码:\n\nresult = knowledge_base_search(query=\"肺部CT报告 基本规范 要求\", index_names=[\"放射科影像诊断报告书写规范\"])\nprint(result)\n\n\n思考:已获取规范内容,现在整合回答。\n根据放射科影像诊断报告书写规范,肺部CT报告的基本要求包括...\n\n---\n\n**示例2:上传报告图片进行质控**\n\n任务:\"这是我刚出的肺部CT报告截图,帮我看看有没有问题\"\n\n思考:用户提供了报告图片,需要先分析图片内容,再结合规范进行审核。\n代码:\n\nreport_content = analyze_image(image_urls_list=[\"s3://radiology-reports/ct-report-001.png\"], query=\"请提取肺部CT报告中的所有文字内容,包括检查所见和诊断意见\")\nprint(report_content)\n\n\n黑暗之光:系统返回 Observation: 检查所见:右肺上叶见磨玻璃密度结节影,直径约0.8cm,边缘欠清晰。双肺纹理增多,未见明显实变及占位。纵隔未见明显肿大淋巴结。诊断意见:右肺上叶磨玻璃结节,建议定期复查。\n\n思考:已获取报告内容,现在需要查阅规范进行审核。\n代码:\n\nstandard = knowledge_base_search(query=\"肺部CT报告 书写规范 质控标准 磨玻璃结节 描述要求\", index_names=[\"放射科影像诊断报告书写规范\"])\nprint(standard)\n\n\n思考:已获得规范,对照报告进行分析,生成质控报告。\n根据规范要求,该报告存在以下问题:1. 磨玻璃结节未描述密度分型;2. 缺少结节三维径线测量;3. 未描述结节边界特征细节。整改建议如下...\n\n---\n\n**示例3:指定医院规范审核**\n\n任务:\"按华山医院的规范帮我审核这份肺部CT报告:检查所见:左肺下叶见条索状高密度影,边缘清晰。诊断意见:左肺下叶陈旧性病变。\"\n\n思考:用户指定了华山医院,需要先查询华山医院的专用规范。\n代码:\n\nhospital_standard = knowledge_base_search(query=\"华山医院 肺部CT报告 书写规范 质控要求\", index_names=[\"放射科影像诊断报告书写规范\"])\nprint(hospital_standard)\n\n\n黑暗之光:系统返回 Observation: 未找到华山医院相关规范,请使用通用规范。\n\n思考:华山医院专用规范未找到,按任务要求使用通用规范进行审核。\n代码:\n\ngeneral_standard = knowledge_base_search(query=\"肺部CT报告 通用规范 陈旧性病变 条索状高密度影 描述要求\", index_names=[\"放射科影像诊断报告书写规范\"])\nprint(general_standard)\n\n\n思考:已获取通用规范,对照报告内容进行审核。\n根据通用规范,该报告的主要问题:1. 缺少病变范围的具体描述;2. 未说明与既往检查的对比情况;3. 诊断意见中缺少必要的鉴别诊断说明。整改建议如下...\n\n---\n\n**示例4:同时查通用规范和指定医院规范**\n\n任务:\"对比一下通用规范和协和医院对肺部CT报告的要求有什么不同\"\n\n思考:需要同时查询通用规范和协和医院规范,两者互不依赖,使用parallel_executor并行执行。\n代码:\n\nresults = parallel_executor(\n tasks=[\n (knowledge_base_search, {\"query\": \"肺部CT报告 通用规范 书写要求\", \"index_names\": [\"放射科影像诊断报告书写规范\"]}, \"general\"),\n (knowledge_base_search, {\"query\": \"协和医院 肺部CT报告 书写规范 特殊要求\", \"index_names\": [\"放射科影像诊断报告书写规范\"]}, \"xiehe\"),\n ],\n)\nprint(results[\"general\"])\nprint(results[\"xiehe\"])\n\n\n思考:已获取两份规范,现在对比分析。\n通用规范与协和医院规范在肺部CT报告要求上的主要差异包括:1. 协和医院要求对结节进行Lung-RADS分级;2. 通用规范要求常规描述纵隔窗,协和医院额外要求描述骨窗。具体对比如下...\n\n---\n\n**示例5:完整质控流程(含图片分析+规范检索+报告生成)**\n\n任务:\"这是一份肺部CT报告的截图,帮我按通用规范做完整质控,生成一份可下载的质控报告\"\n\n思考:需要先分析图片获取报告内容,同时查询通用规范,两者并行执行以提升效率。\n代码:\n\nresults = parallel_executor(\n tasks=[\n (analyze_image, {\"image_urls_list\": [\"s3://reports/ct-lung-2024.png\"], \"query\": \"请完整提取肺部CT报告的所有文字,包括检查技术、检查所见和诊断意见\"}, \"report\"),\n (knowledge_base_search, {\"query\": \"肺部CT报告 形式质控 完整检查清单 评分标准\", \"index_names\": [\"放射科影像诊断报告书写规范\"]}, \"standard\"),\n ],\n timeout=60,\n)\nprint(results[\"report\"])\nprint(results[\"standard\"])\n\n\n思考:已获取报告内容和质控规范,现在逐项对照审核,发现以下问题:1. 未注明扫描层厚和重建算法;2. 检查所见中病灶未测量三维径线;3. 诊断意见缺少随访建议。综合评估结果为不合格,详细整改建议已生成。现在输出质控报告。\n\n根据通用规范对肺部CT报告进行形式质控,结果如下:\n\n**质控报告**\n- 患者信息完整性:合格\n- 检查技术描述:不合格(缺少扫描层厚、重建算法)\n- 病变描述规范性:不合格(未测量三维径线,未描述密度特征)\n- 诊断意见完整性:不合格(缺少随访建议和鉴别诊断)\n- 综合评分:55分(不合格)\n\n**整改建议:**\n1. 补充扫描技术参数:层厚、重建算法、窗宽窗位\n2. 对右肺结节补充描述:三维径线、密度分型、边缘特征\n3. 诊断意见增加:随访时间建议、鉴别诊断考虑\n\n以上为质控报告全文,如需下载请告知。", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-2568" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + }, + { + "class_name": "AnalyzeImageTool", + "name": "analyze_image", + "description": "This tool uses the configured image understanding model to understand images based on your query and then returns a description of the image.\nIt is used to understand and analyze multiple images, with image sources supporting S3 URLs (s3://bucket/key or /bucket/key), HTTP, and HTTPS URLs.\nUse this tool when you want to retrieve information contained in an image and provide the image's URL and your query.", + "inputs": "{\"image_urls_list\": {\"type\": \"array\", \"description\": \"List of image URLs (S3, HTTP, or HTTPS). Supports s3://bucket/key, /bucket/key, http://, and https:// URLs.\", \"description_zh\": \"列表形式输入图片 URL(S3、HTTP 或 HTTPS)。支持 s3://bucket/key、/bucket/key、http:// 和 https:// URL。\"}, \"query\": {\"type\": \"string\", \"description\": \"User's question to guide the analysis\", \"description_zh\": \"用户的问题,用于指导分析\"}}", + "output_type": "array", + "params": { + "selected_model_id": null + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 7466 + ], + "model_names": [ + "deepseek-ai/DeepSeek-V4-Flash" + ], + "business_logic_model_id": 7534, + "business_logic_model_name": null, + "skill_names": [ + "create-docx" + ], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是肺部CT报告形式质控助手,可以帮你审核报告的结构与格式,按医院规范检查并生成整改建议和质控报告。", + "example_questions": [ + "肺部CT报告的基本规范有哪些要求?", + "帮我看看这张肺部CT报告截图有没有形式问题", + "按华山医院规范审核这份肺部CT报告", + "对比一下通用规范和协和医院对肺部CT报告的要求", + "给我做一份完整的肺部CT报告质控,并生成可下载的质控报告" + ] + } + }, + "mcp_info": [], + "name": "lung_ct_qc_assistant", + "display_name": "【医疗】肺部CT报告形式质控助手", + "icon": "🩻", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-2568", + "display_name": "放射科影像诊断报告书写规范", + "description": "", + "documents": [] + } + ] +} diff --git "a/deploy/official-agents/medical/lung_ct_qc_assistant/kb/kb-2568/\345\205\211\346\230\216\345\214\273\351\231\242\346\224\276\345\260\204\347\247\221.docx" "b/deploy/official-agents/medical/lung_ct_qc_assistant/kb/kb-2568/\345\205\211\346\230\216\345\214\273\351\231\242\346\224\276\345\260\204\347\247\221.docx" new file mode 100644 index 0000000000..5bd66e8040 Binary files /dev/null and "b/deploy/official-agents/medical/lung_ct_qc_assistant/kb/kb-2568/\345\205\211\346\230\216\345\214\273\351\231\242\346\224\276\345\260\204\347\247\221.docx" differ diff --git "a/deploy/official-agents/medical/lung_ct_qc_assistant/kb/kb-2568/\346\224\276\345\260\204\347\247\221\345\275\261\345\203\217\350\257\212\346\226\255\346\212\245\345\221\212\344\271\246\345\206\231\350\247\204\350\214\203.docx" "b/deploy/official-agents/medical/lung_ct_qc_assistant/kb/kb-2568/\346\224\276\345\260\204\347\247\221\345\275\261\345\203\217\350\257\212\346\226\255\346\212\245\345\221\212\344\271\246\345\206\231\350\247\204\350\214\203.docx" new file mode 100644 index 0000000000..912348a6be Binary files /dev/null and "b/deploy/official-agents/medical/lung_ct_qc_assistant/kb/kb-2568/\346\224\276\345\260\204\347\247\221\345\275\261\345\203\217\350\257\212\346\226\255\346\212\245\345\221\212\344\271\246\345\206\231\350\247\204\350\214\203.docx" differ diff --git a/deploy/official-agents/medical/lung_ct_qc_assistant/skills/create-docx.zip b/deploy/official-agents/medical/lung_ct_qc_assistant/skills/create-docx.zip new file mode 100644 index 0000000000..de4e44449c Binary files /dev/null and b/deploy/official-agents/medical/lung_ct_qc_assistant/skills/create-docx.zip differ diff --git a/deploy/official-agents/medical/medical_insurance_review_assistant/agent.json b/deploy/official-agents/medical/medical_insurance_review_assistant/agent.json new file mode 100644 index 0000000000..e429fcb441 --- /dev/null +++ b/deploy/official-agents/medical/medical_insurance_review_assistant/agent.json @@ -0,0 +1,98 @@ +{ + "agent_id": 2793, + "agent_info": { + "2793": { + "agent_id": 2793, + "tenant_id": "36984215-00ab-49cd-b3bb-7984e8e81fff", + "name": "medical_insurance_review_assistant", + "display_name": "【医疗】医保合规审查智能体", + "description": "你是一个医保审核医院收费项目助手,能够查询医保规则知识库来获取特定收费项目的条件要求。通过使用knowledge_base_search工具,你可以接收药品名称、检测检验项目等信息,生成详细的审核思维链路,确保收费项目符合医保规定。", + "author": "admin@wmc.com", + "max_steps": 5, + "requested_output_tokens": null, + "is_main_agent": true, + "provide_run_summary": false, + "allow_chat_metadata": false, + "verification_config": { + "enabled": false, + "pass_score": 0.75, + "strictness": "balanced", + "fail_policy": "repair_then_controlled_summary", + "critical_events": [ + "tool_precheck", + "tool_result", + "retrieval", + "code_execution", + "handoff", + "final_answer" + ], + "max_final_rounds": 2, + "llm_verification_enabled": true, + "step_verification_enabled": true, + "final_verification_enabled": true + }, + "context_policy": null, + "duty_prompt": "你是一个医保合规审查助手,专门负责分析医院收费项目是否符合医保报销要求。你能够查询医保规则知识库,获取特定收费项目通过医保审核所需满足的条件。你可以为每一项收费生成清晰的检查思维链路,明确医院需要满足的具体条件。", + "constraint_prompt": "1. 只能使用 knowledge_base_search 工具进行知识检索,不得调用任何其他未授权的工具或助手。\n2. 每次调用 knowledge_base_search 时,必须将 index_names 参数明确指定为 [\"医保药品审核指南\"],不得使用其他索引名称或留空。\n3. 查询内容必须严格围绕医院收费项目的医保报销核查条件,不得检索与医保合规审查无关的信息。\n4. 工具返回的结果仅用于生成检查思维链路,不得直接输出原始检索内容或用于其他分析目的。\n5. 若单次检索未能获取足够信息,可进行多次检索,但每次均需遵守上述索引和查询范围限制。", + "few_shots_prompt": "任务1:\"阿莫西林胶囊的医保报销需要满足什么条件?\"\n\n思考:我需要查询医保药品审核指南知识库,获取阿莫西林胶囊的医保审核条件。\n代码:\n\ninfo = knowledge_base_search(query=\"阿莫西林胶囊 医保审核条件\", index_names=[\"医保药品审核指南\"])\nprint(info)\n\n# 系统返回 Observation: 阿莫西林胶囊:限用于敏感菌所致感染,需提供病原学检查结果或临床诊断依据;门诊使用限二级及以上医疗机构;单次处方量不超过7日常用量。\n\n思考:已获取审核条件,现在生成检查思维链路。\n最终回答:阿莫西林胶囊若要通过医保报销核查,医院需满足以下条件:\n1. 使用范围:仅限用于敏感菌(如溶血链球菌、肺炎链球菌等)所致的感染,需在病历中记录明确的临床诊断(如急性咽炎、社区获得性肺炎等),并建议附上病原学检查结果(如细菌培养+药敏试验)。\n2. 医疗机构级别:门诊报销限二级及以上医疗机构,一级医院或社区卫生服务中心门诊使用可能不予支付。\n3. 处方用量:单次处方量不得超过7日常用量,慢性病等特殊情况需在病历中注明理由并按规定延长。\n\n---\n\n任务2:\"血常规检查项目医保能报销吗?有什么要求?\"\n\n思考:我需要查询医保规则知识库,了解血常规检查的报销条件。\n代码:\n\ninfo = knowledge_base_search(query=\"血常规 医保审核条件\", index_names=[\"医保药品审核指南\"])\nprint(info)\n\n# 系统返回 Observation: 血常规(全血细胞计数):限用于疾病诊断、疗效监测或健康体检中的必要项目;门诊报销需有明确临床指征,如发热、贫血、出血倾向等;住院期间作为常规检查可报销,但重复检查需说明理由。\n\n思考:已获取审核条件,现在生成检查思维链路。\n最终回答:血常规检查若要通过医保报销核查,医院需满足以下条件:\n1. 临床指征:门诊患者必须有明确的临床指征记录,例如发热待查、乏力怀疑贫血、皮肤瘀斑怀疑血小板减少等,单纯“健康体检”或“要求检查”可能被拒付。\n2. 住院使用:住院期间作为入院常规检查或病情监测可报销,但若短期内(如3天内)重复检查,需在病程记录中说明复查原因(如治疗后评估、病情变化等)。\n3. 项目内涵:血常规通常指全血细胞计数(白细胞、红细胞、血小板等),若仅申请单项(如仅查白细胞),需符合单项检查的适应症,否则可能被要求按单项价格支付。\n\n---\n\n任务3:\"一级护理的医保审核条件是什么?\"\n\n思考:我需要查询医保知识库中关于一级护理的报销规则。\n代码:\n\ninfo = knowledge_base_search(query=\"一级护理 医保审核条件\", index_names=[\"医保药品审核指南\"])\nprint(info)\n\n# 系统返回 Observation: 一级护理:限病情危重、需严格卧床、生活完全不能自理的患者;需在医嘱和护理记录中体现每小时巡视、生命体征监测等内容;每日护理费限报1次,不得与特级护理同时计费。\n\n思考:已获取审核条件,现在生成检查思维链路。\n最终回答:一级护理若要通过医保报销核查,医院需满足以下条件:\n1. 病情依据:患者必须符合病情危重、需绝对卧床、生活完全不能自理的标准,如大手术后、休克、昏迷、多器官功能衰竭等,病历中应有相应诊断和护理级别医嘱。\n2. 护理记录:护理记录单需体现每小时巡视一次,并记录生命体征、病情变化、护理措施等,若记录缺失或频次不足,可能被认定为虚假计费。\n3. 计费限制:每日只能收取一次一级护理费,不可与特级护理、重症监护等同时收取;若患者病情好转转为二级护理,应及时调整医嘱和收费。\n\n---\n\n任务4:\"人血白蛋白注射液医保报销有什么限制?\"\n\n思考:我需要查询医保药品审核指南,了解人血白蛋白的报销限制条件。\n代码:\n\ninfo = knowledge_base_search(query=\"人血白蛋白 医保审核条件\", index_names=[\"医保药品审核指南\"])\nprint(info)\n\n# 系统返回 Observation: 人血白蛋白:限抢救、重症或因肝硬化、肾病综合征等导致的重度低蛋白血症(血清白蛋白<25g/L),且需提供血清白蛋白检测报告;门诊使用不予支付;单次处方量不超过3日用量。\n\n思考:已获取审核条件,现在生成检查思维链路。\n最终回答:人血白蛋白注射液若要通过医保报销核查,医院需满足以下条件:\n1. 适应症限制:仅限用于抢救、重症监护患者,或因肝硬化、肾病综合征等明确诊断导致的重度低蛋白血症,且血清白蛋白检测结果必须低于25g/L,病历中需附检验报告单。\n2. 使用场景:门诊使用医保不予支付,仅限住院期间使用,且需在病程记录中详细说明用药理由和预期效果。\n3. 用量控制:单次处方量不得超过3日用量,长期使用需定期评估白蛋白水平,若连续使用超过5天需有科主任审批记录。\n\n---\n\n任务5:\"CT平扫(头颅)的医保审核条件有哪些?\"\n\n思考:我需要查询医保知识库中关于CT平扫的报销规则。\n代码:\n\ninfo = knowledge_base_search(query=\"CT平扫 头颅 医保审核条件\", index_names=[\"医保药品审核指南\"])\nprint(info)\n\n# 系统返回 Observation: CT平扫(头颅):限有神经系统症状或体征,如头痛、呕吐、意识障碍、肢体无力等;外伤患者需有明确头部外伤史;门诊报销需提供门诊病历记录,住院患者需在病程中记录检查必要性;不得与MRI同日重复检查,除非有病情突变记录。\n\n思考:已获取审核条件,现在生成检查思维链路。\n最终回答:头颅CT平扫若要通过医保报销核查,医院需满足以下条件:\n1. 临床指征:患者必须有明确的神经系统症状或体征,例如急性头痛伴呕吐、意识模糊、偏瘫、癫痫发作等,或明确的头部外伤史(如坠落、撞击),病历中需详细记录。\n2. 门诊报销:门诊申请CT需在门诊病历中记录症状、体征及检查目的,单纯“头晕”而无其他警示信号可能被拒付。\n3. 重复检查限制:同一部位CT与MRI不得在同一天内重复计费,除非病程记录中说明病情突然变化(如意识障碍加重)需紧急复查;若已做MRI,再行CT需提供充分理由。", + "enabled": true, + "tools": [ + { + "class_name": "KnowledgeBaseSearchTool", + "name": "knowledge_base_search", + "description": "Performs a local knowledge base search based on your query then returns the top search results. A tool for retrieving domain-specific knowledge, documents, and information stored in the local knowledge base. Use this tool when users ask questions related to specialized knowledge, technical documentation, domain expertise, personal notes, or any information that has been indexed in the knowledge base. Suitable for queries requiring access to stored knowledge that may not be publicly available.", + "inputs": "{\"query\": {\"type\": \"string\", \"description\": \"The search query to perform.\", \"description_zh\": \"要执行的搜索查询词\"}, \"index_names\": {\"type\": \"array\", \"description\": \"The list of index names to search\", \"description_zh\": \"要索引的知识库\", \"nullable\": true}}", + "output_type": "string", + "params": { + "top_k": 3, + "index_names": [ + "kb-1748" + ], + "search_mode": "hybrid", + "rerank": false, + "rerank_model_name": "" + }, + "source": "local", + "usage": null, + "metadata": {}, + "labels": null + } + ], + "managed_agents": [], + "model_ids": [ + 211 + ], + "model_names": [ + "Qwen2.5-32B-Instruct" + ], + "business_logic_model_id": 7534, + "business_logic_model_name": null, + "skill_names": [], + "prompt_template_id": 0, + "prompt_template_name": "system_default", + "model_params_override": null, + "greeting_message": "你好!我是医院的医保合规审查助手,可以帮你查询药品、检查、护理等项目的报销条件,帮助医院通过医保局报销合规审查,减少医院损失。", + "example_questions": [ + "阿莫西林胶囊医保报销需要什么条件?", + "血常规检查医保能报销吗?", + "一级护理的医保审核条件是什么?", + "人血白蛋白注射液报销有什么限制?", + "头颅CT平扫医保审核有哪些要求?" + ] + } + }, + "mcp_info": [], + "name": "medical_insurance_review_assistant", + "display_name": "【医疗】医保合规审查智能体", + "icon": "🧾", + "tags": [], + "version_label": "V1", + "knowledge_bases": [ + { + "logical_index_name": "kb-1748", + "display_name": "医保药品审核指南", + "description": "", + "documents": [] + } + ] +} diff --git "a/deploy/official-agents/medical/medical_insurance_review_assistant/kb/kb-1748/\345\214\273\344\277\235\350\215\257\345\223\201\345\256\241\346\240\270\346\214\207\345\215\227.txt" "b/deploy/official-agents/medical/medical_insurance_review_assistant/kb/kb-1748/\345\214\273\344\277\235\350\215\257\345\223\201\345\256\241\346\240\270\346\214\207\345\215\227.txt" new file mode 100644 index 0000000000..5da1e28604 --- /dev/null +++ "b/deploy/official-agents/medical/medical_insurance_review_assistant/kb/kb-1748/\345\214\273\344\277\235\350\215\257\345\223\201\345\256\241\346\240\270\346\214\207\345\215\227.txt" @@ -0,0 +1,398 @@ +# 医保药品审核异常知识库 + +> 本知识库依据国家医保局公开发布的智能监管“两库”规则和知识点、各省市医保智能审核规则库编制,适用于医保审核人员对药品费用进行合规性审核。 + + +## 一、药品审核规则分类总览 + +根据国家医保局公开发布的智能监管“两库”规则和知识点,与药品相关的审核规则共分为以下类别: + +| 批次 | 规则类别 | 说明 | +|------|---------|------| +| 第一批 | 药品限工伤保险 | 仅限工伤保险支付,基本医疗保险不予支付 | +| 第一批 | 药品限成人使用 | 成人用药不得用于儿童患者 | +| 第一批 | 药品限儿童使用 | 儿童用药不得用于成人患者 | +| 第一批 | 药品儿童专用 | 仅限儿童使用,成人使用不予支付 | +| 第一批 | 中药饮片单复方均不予支付 | 特定中药饮片不纳入基金支付范围 | +| 第三批 | 药品限工伤保险使用 | 同第一批-1 | +| 第三批 | 限生育保险使用 | 药品仅限生育保险支付 | +| 第四批 | 药品限就医方式使用 | 限定门诊/住院使用 | +| 第五批 | 药品限医疗机构级别使用 | 限定特定级别医疗机构使用 | +| 第六批 | 药品限支付天数 | 使用药品的日期不得超过限制天数 | + +此外,还存在以下审核规则:**药品区分性别使用、超限定频次、超限定疗程、限定适应症(条件)用药、重复用药、超量开药、无处方开药、药品对码错误**等。 + + +## 二、各类药品审核异常详解 + +### (一)人口学特征类审核异常 + +#### 1. 药品限成人使用 + +**规则说明**:参保人年龄不符合成人年龄限制时,医保基金不予支付。 + +**审核逻辑**: +- 药品目录备注中明确标注限成人使用的药品 +- 患者年龄<18周岁(部分药品界定为<16周岁)时,不得开具该药品 + +**典型案例**:部分药品说明书明确仅限成人使用,如某些抗高血压药物、降脂药物等。 + +**知识库对应数量**:第一批共涉及115条药品、486个代码。 + +**审核要点**: +1. 此项收费需要满足患者年龄≥18周岁 +2. 请检查患者全景信息视图中的**年龄/出生日期**字段 +3. 确认患者是否属于“成人”定义范围 + +#### 2. 药品限儿童使用 + +**规则说明**:参保人年龄不符合儿童年龄限制时(通常为>18周岁),医保基金不予支付。 + +**审核逻辑**: +- 药品目录备注中明确限儿童使用的药品 +- 患者年龄>18周岁不得使用 + +**典型案例**: +- **左西替利嗪口服液体剂**:限儿童(18周岁及以下)使用时,医保基金予以支付 +- **右旋糖酐铁口服液体剂**:限儿童(18周岁及以下)缺铁性贫血患者使用 +- **人生长激素(重组人生长激素)**:限儿童(18周岁及以下)生长激素缺乏症患者使用 + +**知识库对应数量**:第一批共涉及11条药品、43个代码。 + +**审核要点**: +1. 此项收费需要满足患者年龄≤18周岁 +2. 请检查患者全景信息视图中的**年龄/出生日期**字段 +3. 部分药品有更严格的年龄限制(如6周岁以下),需逐条核对药品备注 + +#### 3. 药品儿童专用 + +**规则说明**:药品说明书明确仅限儿童使用的药品,成人使用不予支付。 + +**审核逻辑**: +- 药品说明书标注“儿童专用”或明确仅限儿童使用 +- 患者年龄不符合儿童年龄限制时不予支付 + +**典型案例**: +- 小儿专用剂型药品(如小儿氨酚黄那敏颗粒、小儿止咳糖浆等) +- 儿科处方药 + +**知识库对应数量**:第一批共涉及271条药品、4439个代码。 + +**审核要点**: +1. 此项收费需要满足患者符合药品说明书规定的儿童年龄范围 +2. 请检查患者全景信息视图中的**年龄/出生日期**字段 +3. 区分“限儿童使用”与“儿童专用”的严格程度差异 + +#### 4. 药品区分性别使用 + +**规则说明**:男性不得开具女性专用药,反之亦然。 + +**审核逻辑**: +- 药品适应症限定的性别与患者实际性别不符 +- 审核系统自动比对药品适用性别与患者性别 + +**特殊例外**:国家医保局已明确,部分药品虽然在说明书中标注性别限制,但临床上有跨性别使用的合理性,暂不纳入规则。如:**盐酸坦索罗辛缓释胶囊**说明书“用于前列腺增生症引起的排尿障碍”,一般应限定男性使用,但临床泌尿外科部分女性患者使用可改善症状、促排石,可按合理性用药处理。 + +**审核要点**: +1. 此项收费需要满足患者性别与药品适用性别一致 +2. 请检查患者全景信息视图中的**性别**字段 +3. 对于跨性别用药,需查看是否存在临床合理性依据 + + +### (二)支付规则类审核异常 + +#### 5. 药品限工伤保险 + +**规则说明**:就诊信息中的药品,仅限工伤保险时可支付,基本医疗保险基金支付则违反规则。 + +**审核逻辑**: +- 药品目录备注中明确“限工伤保险” +- 参保人险种应为“工伤保险”,不得为“基本医疗保险” + +**知识库对应数量**:第一批共涉及9条药品、84个代码。 + +**审核要点**: +1. 此项收费需要满足参保人类别为工伤保险 +2. 请检查患者全景信息视图中的**险种类型**字段 +3. 确认是否为工伤相关诊疗 + +#### 6. 限生育保险使用 + +**规则说明**:药品仅限生育保险支付,基本医疗保险不予支付。 + +**审核逻辑**: +- 药品目录备注中明确“限生育保险” +- 参保人应为生育保险参保人员 +- 就诊类型应为生育相关诊疗 + +**审核要点**: +1. 此项收费需要满足参保人类别为生育保险 +2. 请检查患者全景信息视图中的**险种类型**字段 +3. 确认就诊类型是否为生育相关 + +#### 7. 药品限就医方式 + +**规则说明**:药品限定仅限门诊或仅限住院使用。 + +**审核逻辑**: +- 药品目录备注中明确“限门诊使用”或“限住院使用” +- 患者就诊类型与使用限制不符时不予支付 + +**典型案例**:部分注射剂型、高风险药品仅限住院使用。 + +**审核要点**: +1. 此项收费需要满足就诊类型(门诊/住院)符合药品限制 +2. 请检查患者全景信息视图中的**就诊类型**字段 +3. 确认药品使用场景是否符合规定 + +#### 8. 药品限医疗机构级别 + +**规则说明**:部分药品限定在特定级别的医疗机构使用。 + +**审核逻辑**: +- 药品目录备注中明确“限二级及以上医疗机构使用”等 +- 开单医疗机构级别不符合要求时不予支付 + +**典型案例**: +- **参麦注射液**:限二级及以上医疗机构并有急救、抢救临床证据或肿瘤放化疗证据的患者支付 +- **丹参注射液**:限二级及以上医疗机构使用支付 +- **注射用益气复脉(冻干)**:限二级及以上医疗机构冠心病心绞痛及冠心病所致左心功能不全II-III级的患者 + +**审核要点**: +1. 此项收费需要满足医疗机构级别符合药品限制 +2. 请检查**本院级别**是否达到要求 +3. 部分药品同时有适应症限制,需一并核对 + + +### (三)用药行为类审核异常 + +#### 9. 药品限支付天数/超限定疗程 + +**规则说明**:药品有疗程或天数限制,超过限定天数不予支付。 + +**审核逻辑**: +- 药品目录备注中明确“支付不超过X天” +- 实际使用天数超过限制时,超出部分不予支付 + +**典型案例**: +- **依达拉奉右莰醇**:限新发的急性缺血性脑卒中患者在发作48小时内开始使用,支付不超过14天 +- **丁苯酞氯化钠**:限新发的急性缺血性脑卒中患者在发作48小时内开始使用,支付不超过14天 +- **银杏叶提取物注射液**:限缺血性心脑血管疾病急性期住院患者;限耳部血流及神经障碍患者。支付不超过14天 +- **注射用益气复脉(冻干)**:单次住院最多支付14天 + +**审核要点**: +1. 此项收费需要满足使用天数/疗程未超过限制 +2. 请检查患者全景信息视图中的**用药起止时间**和**用药天数** +3. 确认是否为急性期/首次发病(部分药品限制发作后X小时内开始使用) + +#### 10. 超限定频次 + +**规则说明**:药品每日/每次就诊限定使用次数,超过限定次数不予支付。 + +**审核逻辑**: +- 医疗服务项目/药品使用有频次限制 +- 实际使用频次超过规定 + +**典型案例**:部分静脉注射药品每日限用1次,多次使用需有特殊依据。 + +**审核要点**: +1. 此项收费需要满足使用频次未超过规定 +2. 请检查患者全景信息视图中的**给药频次记录** +3. 确认是否存在临床特殊情况 + +#### 11. 限定适应症(条件)用药 + +**规则说明**:药品支付限定于特定适应症,诊断与药品适应症不匹配时不予支付。 + +**审核逻辑**: +- 药品目录备注中明确适应症限制 +- 患者诊断不包含限定适应症时不予支付 + +**典型案例**: +- **人生长激素**:限儿童生长激素缺乏症患者 +- **银杏叶提取物注射液**:限缺血性心脑血管疾病急性期住院患者;限耳部血流及神经障碍患者 + +**审核要点**: +1. 此项收费需要满足患者诊断符合药品适应症 +2. 请检查患者全景信息视图中的**诊断名称**和**ICD编码** +3. 确认是否存在其他支撑条件(如“急性期”“新发”等) + +#### 12. 重复用药 + +**规则说明**:同一治疗目的不得重复使用同类药品。 + +**审核逻辑**: +- 同一患者、同一次就诊开具药理作用相同或相似的多种药品 +- 缺乏联合用药必要性依据 + +**审核要点**: +1. 此项收费需要确认不存在重复用药情形 +2. 请检查患者全景信息视图中的**本次就诊全部处方**,比对药品药理分类 +3. 确认联合用药是否存在临床必要性 + +#### 13. 超量开药 + +**规则说明**:单次处方药量超过规定天数,超出部分不予支付。 + +**审核逻辑**: +- 门诊慢性病药品单次处方一般不超过30天量 +- 特殊药品有更严格的限量要求 + +**特殊药品限量**:同种疾病同一治疗周期原则上只可使用一种特药,用药周期不超过一年,一次不能超过一个月。 + +**审核要点**: +1. 此项收费需要满足处方量未超过规定天数 +2. 请检查患者全景信息视图中的**处方数量**和**用药天数** +3. 核对是否为特殊管理药品 + +#### 14. 无处方开药 + +**规则说明**:使用医保基金支付药品必须有对应的处方。 + +**审核逻辑**: +- 药品结算时需关联有效处方 +- 无处方或处方信息不完整时不予支付 + +**审核要点**: +1. 此项收费需要存在有效处方 +2. 请检查患者全景信息视图中的**处方信息**字段 +3. 确认处方医师是否具备相应资质 + +#### 15. 药品对码错误 + +**规则说明**:药品编码与医保目录代码不一致,导致错误报销。 + +**审核逻辑**: +- 药品结算时使用的医保编码与实际药品不符 +- 编码使用错误导致违规支付 + +**审核要点**: +1. 此项收费需要药品编码与实际药品一致 +2. 请检查患者全景信息视图中的**药品名称**与**医保编码**是否匹配 +3. 确认是否存在人为或系统性对码错误 + + +### (四)中药饮片类审核异常 + +#### 16. 中药饮片单方不予支付/单复方均不予支付 + +**规则说明**:《国家基本医疗保险、工伤保险和生育保险药品目录》中部分中药饮片标注“□”,单独使用时或全部由这些饮片组成的处方,医保基金不予支付;部分标注“单复方均不予支付”的,在任何情况下均不予支付。 + +**审核逻辑**: +- 审方系统识别处方组成 +- 单味使用或全方均为标注“□”饮片时,不予支付 +- 标注“单复方均不予支付”的,无论何种配伍均不予支付 + +**典型案例**: +- **菊花**(单方不支付):单独使用菊花或全方均为不支付饮片时不予支付 +- **当归**(单方不支付) +- **枸杞子**(单方不支付) + +**知识库对应数量**:第一批共涉及1191条中药饮片信息。 + +**审核要点**: +1. 此项收费需要确认处方组成是否允许支付 +2. 请检查患者全景信息视图中的**处方明细**,判断是否为单方或全方均为不支付饮片 +3. 部分饮片为“单复方均不予支付”,需特别注意 + + +### (五)其他审核异常 + +#### 17. 特殊药品(特药)管理异常 + +**规则说明**:特药需“事前审查、实名备案”,未经备案使用不予支付。 + +**审核逻辑**: +- 患者未完成特药资格认定备案 +- 超出备案有效期(一般为一年)未及时延续 +- 特药责任医师确认后超过6个月未治疗,中断治疗超过3个月 +- 需要调整用药种类但未重新备案 + +**备案流程**: +1. 定点鉴定医疗机构填写特殊药品申请鉴定备案表 +2. 责任医师鉴定、制定用药方案 +3. 区/县医保经办中心审定备案 + +**审核要点**: +1. 此项收费需要患者已完成特药资格备案 +2. 请检查患者全景信息视图中的**特药备案信息**(备案号、有效期、责任医师、指定机构) +3. 确认备案是否有效期内 +4. 变更用药种类需重新备案 + +#### 18. 门急诊就诊和配药异常 + +**规则说明**:参保人就医频次或配药费用达到异常阈值时,触发审核。 + +**审核逻辑**: + +| 异常类型 | 触发标准 | +|---------|---------| +| 异常门急诊就医频次 | 月门诊就医≥15次;或月门急诊≥20次;或年度门诊≥100次 | +| 异常费用 | 月门诊费用≥8000元;或年度门诊费用≥30000元;或年度门急诊费用≥35000元 | +| 单日就诊异常 | 单日门诊超4次累计3天/月;或单日超3家医院累计3天/月 | +| 药品配药异常 | 月药店配药费用≥8000元 | + +**审核要点**: +1. 此项收费需要确认患者费用/频次未达异常阈值 +2. 请检查患者全景信息视图中的**历史就诊记录**和**历史费用记录** +3. 已触发异常的,需进一步核查是否存在分散开药、冒名开药等行为 + +#### 19. 中药配伍禁忌 + +**规则说明**:中药处方存在“十八反”“十九畏”等配伍禁忌时,应当提示或拦截。 + +**审核逻辑**: +- 处方中存在相反、相畏的配伍 +- 需核对是否存在特殊临床依据 + +**审核要点**: +1. 此项收费需要处方中不存在配伍禁忌 +2. 请检查患者全景信息视图中的**中药处方组成** +3. 确认是否存在特殊辩证论治依据 + + +## 三、药品审核思维链路 + +当接收到待审核的药品项目时,请按以下步骤执行: + +**第1步:确认药品基本信息** +- 请核对:药品名称、剂型、规格、医保编码 +- 请检查:药品是否在医保目录内 + +**第2步:核对人口学特征条件** +- 请检查:患者全景信息视图中的**年龄/出生日期** +- 请检查:患者全景信息视图中的**性别** +- 判断:是否符合药品的成人/儿童/性别限制 + +**第3步:核对医保支付条件** +- 请检查:患者全景信息视图中的**险种类型**(基本医保/工伤保险/生育保险) +- 请检查:患者本次**就诊类型**(门诊/住院) +- 请检查:**本院/开单科室级别**是否符合药品限制 +- 请核对:患者**诊断名称/ICD编码**是否符合药品适应症 + +**第4步:核对用药行为合规性** +- 请检查:处方**起止时间**和**用药天数**是否超过限制 +- 请检查:**给药频次**是否超过规定 +- 请检查:同次就诊**全部处方**中是否存在重复用药 +- 请检查:**处方量**是否超过规定天数 +- 请确认:是否存在**有效处方** + +**第5步:核对特殊管理要求(如适用)** +- 请检查:患者**特药备案信息**(备案号、有效期、责任医师) +- 请确认:是否属于**中药饮片**及支付限制 +- 请检查:**历史就诊及费用记录**是否触发异常频次/费用阈值 + +**第6步:输出审核结论** +- 全部合规 → 通过 +- 存在异常 → 明确异常类型及依据,按规定处理 + + +## 四、合规提示 + +> **【执业资格提示】** 处方开具医师必须具备相应执业资格与处方权。 + +> **【用药安全提示】** 遵从处方管理办法,核对过敏史、禁忌症,遵循级联使用原则。 + +> **【医保规范提示】** 请严格对照最新医保目录与支付政策,确保合规收费。 + +> **【药品追溯提示】** 药品采购应严格核验随货同行单及发票,确保药品追溯码“应采尽采、依码结算、依码支付”。 \ No newline at end of file diff --git "a/deploy/official-agents/medical/medical_insurance_review_assistant/kb/kb-1748/\345\233\275\345\256\266\345\237\272\346\234\254\345\214\273\347\226\227\344\277\235\351\231\251\343\200\201\345\267\245\344\274\244\344\277\235\351\231\251\345\222\214\347\224\237\350\202\262\344\277\235\351\231\251\350\215\257\345\223\201\347\233\256\345\275\225.pdf" "b/deploy/official-agents/medical/medical_insurance_review_assistant/kb/kb-1748/\345\233\275\345\256\266\345\237\272\346\234\254\345\214\273\347\226\227\344\277\235\351\231\251\343\200\201\345\267\245\344\274\244\344\277\235\351\231\251\345\222\214\347\224\237\350\202\262\344\277\235\351\231\251\350\215\257\345\223\201\347\233\256\345\275\225.pdf" new file mode 100644 index 0000000000..097516b6bd Binary files /dev/null and "b/deploy/official-agents/medical/medical_insurance_review_assistant/kb/kb-1748/\345\233\275\345\256\266\345\237\272\346\234\254\345\214\273\347\226\227\344\277\235\351\231\251\343\200\201\345\267\245\344\274\244\344\277\235\351\231\251\345\222\214\347\224\237\350\202\262\344\277\235\351\231\251\350\215\257\345\223\201\347\233\256\345\275\225.pdf" differ diff --git a/deploy/sql/migrations/README.md b/deploy/sql/migrations/README.md index 36e004100b..308d16befb 100644 --- a/deploy/sql/migrations/README.md +++ b/deploy/sql/migrations/README.md @@ -35,9 +35,11 @@ COLUMN IF NOT EXISTS`, and conflict-safe inserts where possible. Historical migrations through v2.4.0 are consolidated by minor version in `v2.2_merged_migrations.sql`, `v2.3_merged_migrations.sql`, and `v2.4_merged_migrations.sql`, and migrations since v2.4.0 are consolidated in -`v2.5.0_merged_migrations.sql` and `v2.6.0_merged_migrations.sql` (which merges -all migrations applied after the v2.5.1 release, through v2.6.0). Newer -migrations remain separate until their minor-version history is consolidated. +`v2.5.0_merged_migrations.sql`, `v2.6.0_merged_migrations.sql` (which merges +all migrations applied after the v2.5.1 release, through v2.6.0), and +`v2.7.0_merged_migrations.sql` (which merges all migrations applied after +the v2.6.1 release, through v2.7.0). Newer migrations remain separate until +their minor-version history is consolidated. Important: do NOT modify a `*_merged_migrations.sql` file after it has been deployed. Because it bundles many historical migrations, even a comment-only diff --git a/deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql b/deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql deleted file mode 100644 index d0109fea9b..0000000000 --- a/deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Deploy only after all processes using the retired interaction engine have stopped. --- Ordinary conversation messages and units are intentionally preserved. -DROP TABLE IF EXISTS nexent.human_event_t; -DROP TABLE IF EXISTS nexent.human_execution_t; -DROP TABLE IF EXISTS nexent.human_request_t; -DROP TABLE IF EXISTS nexent.human_run_t; --- This function is used exclusively by the four tables removed above. -DROP FUNCTION IF EXISTS nexent.human_interaction_audit_timestamp(); diff --git a/deploy/sql/migrations/v2.6.1_002_agent_protocol_repair_retry.sql b/deploy/sql/migrations/v2.6.1_002_agent_protocol_repair_retry.sql new file mode 100644 index 0000000000..c1f1d29c0a --- /dev/null +++ b/deploy/sql/migrations/v2.6.1_002_agent_protocol_repair_retry.sql @@ -0,0 +1,8 @@ +ALTER TABLE nexent.ag_tenant_agent_t + ADD COLUMN IF NOT EXISTS enable_protocol_repair_retry BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE nexent.ag_tenant_agent_t + ALTER COLUMN enable_protocol_repair_retry SET DEFAULT FALSE; + +COMMENT ON COLUMN nexent.ag_tenant_agent_t.enable_protocol_repair_retry IS + 'Whether this agent uses strict output validation and silent protocol repair'; diff --git a/deploy/sql/migrations/v2.7.0_merged_migrations.sql b/deploy/sql/migrations/v2.7.0_merged_migrations.sql new file mode 100644 index 0000000000..0c191bf111 --- /dev/null +++ b/deploy/sql/migrations/v2.7.0_merged_migrations.sql @@ -0,0 +1,137 @@ +-- Nexent merged SQL migrations: v2.7.0 +-- Previous release tag: v2.6.1 +-- Source bodies are embedded byte-for-byte in deployment order. +-- Do not reorder or rewrite sections without equivalence validation. + +-- Source migration: v2.6.0_z_agent_repository_icon_url.sql +-- Source SHA-256: 5ffc0a0389f5ed3ab0538139f0870f2d35fb10fdcbb91a62555774e886842d89 + +-- Repository listings now store an optional uploaded image URL. Legacy emoji +-- values fall back to the deterministic agent icon after this migration. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'nexent' AND table_name = 'ag_agent_repository_t' + AND column_name = 'icon' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'nexent' AND table_name = 'ag_agent_repository_t' + AND column_name = 'icon_url' + ) THEN + ALTER TABLE nexent.ag_agent_repository_t RENAME COLUMN icon TO icon_url; + UPDATE nexent.ag_agent_repository_t SET icon_url = NULL; + END IF; +END $$; + +ALTER TABLE nexent.ag_agent_repository_t + ALTER COLUMN icon_url TYPE VARCHAR(1024); + +COMMENT ON COLUMN nexent.ag_agent_repository_t.icon_url IS + 'Repository icon URL; NULL uses the agent ID based default icon'; + + +-- Source migration: v2.6.1_001_remove_human_interaction.sql +-- Source SHA-256: 08f51328a6de8a7537265d5087be872b33ea8f1282fc892a0f5b1f4baa364479 + +-- Deploy only after all processes using the retired interaction engine have stopped. +-- Ordinary conversation messages and units are intentionally preserved. +DROP TABLE IF EXISTS nexent.human_event_t; +DROP TABLE IF EXISTS nexent.human_execution_t; +DROP TABLE IF EXISTS nexent.human_request_t; +DROP TABLE IF EXISTS nexent.human_run_t; +-- This function is used exclusively by the four tables removed above. +DROP FUNCTION IF EXISTS nexent.human_interaction_audit_timestamp(); + + +-- Source migration: v2.6.1_002_remove_dev_models_menu.sql +-- Source SHA-256: e1c57d58d6754351c05820017e0bc24774c8d2728151ca517a186631338e35af + +-- Remove the model management page from the DEV role menu. +-- Developers must not see or manage models; they only consume +-- administrator-configured models. RESOURCE MODEL READ is kept so +-- model pickers in agent editing keep working. +-- Pattern follows v2.3 migration that removed ASSET_OWNER /owner-manage. +DELETE FROM nexent.role_permission_t +WHERE user_role = 'DEV' + AND permission_category = 'VISIBILITY' + AND permission_type = 'LEFT_NAV_MENU' + AND permission_subtype = '/models'; + + +-- Source migration: v2.6.2_001_agent_workbench.sql +-- Source SHA-256: 5b210092620c8088caf1f39514a54c2c35d74bd578d26cd24d95ce224af241e7 + +-- Add protected platform Agent identity for the intelligent workbench. +ALTER TABLE nexent.ag_tenant_agent_t + ADD COLUMN IF NOT EXISTS system_key VARCHAR(100), + ADD COLUMN IF NOT EXISTS agent_origin VARCHAR(20) NOT NULL DEFAULT 'USER', + ADD COLUMN IF NOT EXISTS system_revision VARCHAR(100); + +COMMENT ON COLUMN nexent.ag_tenant_agent_t.system_key IS + 'Stable platform-owned Agent key; NULL for user-created Agents'; +COMMENT ON COLUMN nexent.ag_tenant_agent_t.agent_origin IS + 'Agent ownership origin: USER or SYSTEM'; +COMMENT ON COLUMN nexent.ag_tenant_agent_t.system_revision IS + 'Server-controlled Nexent release revision applied to a system Agent'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_tenant_system_agent_active_draft + ON nexent.ag_tenant_agent_t (tenant_id, system_key) + WHERE version_no = 0 + AND delete_flag != 'Y' + AND system_key IS NOT NULL; + +-- Creation permissions are required by the server-side NL2 adapters. Permission +-- IDs are migration-owned and explicit; ordinary USER is intentionally excluded. +WITH required_grants (role_permission_id, + user_role, + permission_category, + permission_type, + permission_subtype +) AS ( + VALUES + (1701, 'SU', 'RESOURCE', 'AGENT', 'CREATE'), + (1702, 'ADMIN', 'RESOURCE', 'AGENT', 'CREATE'), + (1703, 'DEV', 'RESOURCE', 'AGENT', 'CREATE'), + (1704, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'CREATE'), + (1705, 'SPEED', 'RESOURCE', 'AGENT', 'CREATE'), + (1706, 'SU', 'RESOURCE', 'SKILL', 'CREATE'), + (1707, 'ADMIN', 'RESOURCE', 'SKILL', 'CREATE'), + (1708, 'DEV', 'RESOURCE', 'SKILL', 'CREATE'), + (1709, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'CREATE'), + (1710, 'SPEED', 'RESOURCE', 'SKILL', 'CREATE') +) +INSERT INTO nexent.role_permission_t ( + role_permission_id, + user_role, + permission_category, + permission_type, + permission_subtype +) +SELECT + required_grants.role_permission_id, + required_grants.user_role, + required_grants.permission_category, + required_grants.permission_type, + required_grants.permission_subtype +FROM required_grants +WHERE NOT EXISTS ( + SELECT 1 + FROM nexent.role_permission_t AS existing + WHERE existing.user_role = required_grants.user_role + AND existing.permission_category = required_grants.permission_category + AND existing.permission_type = required_grants.permission_type + AND existing.permission_subtype = required_grants.permission_subtype +); + +-- Persist the canonical Workbench declaration and its optimistic-lock version. +ALTER TABLE nexent.conversation_record_t + ADD COLUMN IF NOT EXISTS workbench_config JSONB, + ADD COLUMN IF NOT EXISTS workbench_config_version INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN nexent.conversation_record_t.workbench_config IS + 'Canonical schema-v3 Workbench conversation declaration; resolved runtime artifacts are never persisted'; + +COMMENT ON COLUMN nexent.conversation_record_t.workbench_config_version IS + 'Monotonic optimistic-lock version for workbench_config'; + diff --git a/doc/docs/en/quick-start/installation.md b/doc/docs/en/quick-start/installation.md index 11f9fba71e..3a049ace5e 100644 --- a/doc/docs/en/quick-start/installation.md +++ b/doc/docs/en/quick-start/installation.md @@ -539,6 +539,74 @@ NORTHBOUND_EXTERNAL_URL=https://api.yourdomain.com/api > **Important**: The URL must include the `/api` suffix because the Northbound service uses FastAPI's `root_path="/api"` configuration. +## 🤖 Official Agent Deployment + +After Nexent is deployed, you can install official agents from the official agent resources included in the deployment package. Official agent deployment is decoupled from the Nexent platform deployment and does not run automatically when the platform starts. + +### Prerequisites + +Before deployment, make sure that: + +1. Nexent has been deployed with Docker or Kubernetes; +2. The `nexent-config` service is running and database initialization is complete; +3. The deployment package contains `deploy/official-agents`; +4. Docker deployments use Git Bash or WSL to run the script; Kubernetes deployments use a terminal with access to the target cluster. + +No additional environment variables are required, and users do not need to run Git commands, `docker cp`, or the synchronization API manually. + +### Interactive Deployment + +Run the following command from the Nexent repository root: + +```bash +bash deploy/deploy-official-agents.sh +``` + +The script scans the actual first-level directories under `deploy/official-agents` and presents them as the available profiles. You can enter one profile, multiple profiles such as `2,3`, or `all` to install every profile. The script validates the directories and `agent.json` files. Invalid selections stop before any copy or synchronization operation. + +For Docker deployments, the selected profiles are copied to `/mnt/nexent/official-agents/` in the `nexent-config` container and then synchronized to the official agent repository. + +For Kubernetes deployments, run: + +```bash +bash deploy/deploy-official-agents.sh --kubernetes +``` + +To specify a namespace: + +```bash +bash deploy/deploy-official-agents.sh \ + --kubernetes \ + --namespace custom-namespace +``` + +### Using Official Agents After Deployment + +After deployment, users can find the templates in the **Agent Repository** under the official agent list. When copying an official agent, follow the dialog to select models and choose whether to reuse or create the required knowledge bases, Skills, and MCP configurations. + +The deployment stage only publishes templates. It does not create knowledge bases for every tenant. When an official agent includes a knowledge base, the target tenant must have an available embedding model when the agent is copied for the first time. + +### Verification + +For Docker deployments, inspect the copied resources with: + +```bash +docker exec nexent-config find /mnt/nexent/official-agents -name agent.json -print +``` + +Then sign in to Nexent, open the **Agent Repository**, switch to the official agent list, and confirm that the selected profiles are available. + +### Delete an Official Agent + +Deleting an official template requires Super Administrator privileges: + +1. Open **Resource Management**; +2. Open the **Agents** page; +3. Click **Manage Official Agents**; +4. Select the template and confirm deletion. + +This removes the official repository listing, the source Agent in the system tenant, and the corresponding server-side Bundle files. Agent copies already created in regular tenants are not deleted. + ## 💡 Need Help - Browse the [FAQ](./faq) for common install issues diff --git a/doc/docs/en/user-guide/agent-development/agent-configuration.md b/doc/docs/en/user-guide/agent-development/agent-configuration.md index 715fcdec48..9a03e8d585 100644 --- a/doc/docs/en/user-guide/agent-development/agent-configuration.md +++ b/doc/docs/en/user-guide/agent-development/agent-configuration.md @@ -70,6 +70,42 @@ Agents can use tools and skills to complete tasks, including local capabilities On the **Select Agent Tools** tab, click **MCP Config** to connect a remote or containerized MCP service, or convert an existing API into MCP tools. For all three connection methods, OpenAPI requirements, service management, and tool testing, see [Integrate MCP Services](../../integration/integration-in/mcp.md). +### 🔐 Pass User Information to Tools (Tool-side Authorization) + +For MCP tools, the platform injects the **current caller's user information** only when the tool declares the conventional fields below. External A2A agents receive the same trusted snapshot under `metadata.user_context`. + +🔔 **Platform boundary**: the platform itself performs no authorization for tools; it only passes through the authenticated session identity. Authorization is the tool's responsibility. + +**How to declare**: if a tool's input schema defines any of the conventional field names below, the platform treats it as requesting that user information and fills the field with the current user's value at call time: + +| Conventional field | Meaning | +|--------------------|---------| +| `tenant_id` | Tenant ID | +| `tenant_name` | Tenant name | +| `user_id` | User ID | +| `user_name` | Login name (currently the user's email) | +| `user_account` | User account (email) | +| `user_groups` | List of user-group names the user belongs to | + +**Example**: a data query tool that enforces data permissions by caller account and groups only needs to declare `user_account` and `user_groups` in its inputSchema: + +```json +{ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Query content" }, + "user_account": { "type": "string", "description": "Caller account (injected by the platform)" }, + "user_groups": { "type": "array", "items": { "type": "string" }, "description": "Caller user groups (injected by the platform)" } + } +} +``` + +> 💡 **Notes**: +> +> - These conventional fields are removed from both the model-visible tool signature and rendered tool context: the model neither sees nor fills them, and injected values come only from the current authenticated session, so they cannot be forged +> - Undeclared conventional fields are never injected and do not affect the tool's existing parameters +> - External A2A agents receive the trusted snapshot under `metadata.user_context`; a same-named value in chat metadata is ignored + ### ⚙️ Custom Tools You can refer to the following guides to develop your own tools and integrate them into Nexent to enrich agent capabilities: diff --git a/doc/docs/zh/backend/human-in-the-loop-implementation.md b/doc/docs/zh/backend/human-in-the-loop-implementation.md index 2831dca428..769337ddde 100644 --- a/doc/docs/zh/backend/human-in-the-loop-implementation.md +++ b/doc/docs/zh/backend/human-in-the-loop-implementation.md @@ -37,7 +37,7 @@ Web 直接复用原 `ClarificationCard` 的外框、图标、文本/单选/多 保留普通 `POST /agent/run`、`GET /agent/stop/{run_id}` 和 northbound 对应普通能力。携带 `enable_hitl`、`hitl_run_id`、`hitl_after_event` 的旧执行请求明确返回参数错误,包括 false、null 和零值;旧专属路由不再注册。 -新增 `deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql`,依次删除 `human_event_t`、`human_execution_t`、`human_request_t`、`human_run_t` 和仅供四表使用的审计函数,不使用 CASCADE。已合入的初始化及历史 SQL 保持原样。新安装先执行历史 SQL,再执行清理迁移,最终不存在四表。 +清理迁移原为 `deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql`,现已整合进 `deploy/sql/migrations/v2.7.0_merged_migrations.sql`(源节:`v2.6.1_001_remove_human_interaction.sql`),依次删除 `human_event_t`、`human_execution_t`、`human_request_t`、`human_run_t` 和仅供四表使用的审计函数,不使用 CASCADE。已合入的初始化及历史 SQL 保持原样。新安装先执行历史 SQL,再执行清理迁移,最终不存在四表。 部署时须先停止旧进程和旧 scheduler,再同步更新 Web/runtime 并执行清理迁移。清理直接删除旧 HITL 数据,不提供搬迁或执行恢复。本次开发验证只对隔离临时 PostgreSQL 容器执行迁移,没有部署或修改现有业务数据库。 diff --git a/doc/docs/zh/deployment/official-agents.md b/doc/docs/zh/deployment/official-agents.md new file mode 100644 index 0000000000..e2e851d0a2 --- /dev/null +++ b/doc/docs/zh/deployment/official-agents.md @@ -0,0 +1,410 @@ +# 🚀 官方智能体部署 + +本文介绍如何在 Nexent 主平台部署完成后,单独获取并同步官方智能体。 + +## 一、部署概述 + +官方智能体部署与 Nexent 主平台部署相互独立: + +- Nexent 主安装只负责启动平台服务并准备官方资源挂载目录; +- 官方智能体部署脚本负责获取、筛选和复制指定行业的官方资源; +- 同步脚本负责将资源登记到智能体仓库; +- 部署阶段不会为租户创建知识库,也不会要求配置向量模型; +- 用户从智能体仓库复制官方智能体时,才会按需创建或复用知识库、Skill 和 MCP。 + +## 二、前置条件 + +执行官方智能体部署前,请确认: + +1. Nexent 已经完成 Docker 或 Kubernetes 部署; +2. `nexent-config` 服务已经启动并可以正常访问数据库; +3. 执行脚本的机器可以访问 Nexent 项目目录; +4. 从 Agent Hub 获取资源时,机器已安装 Git;如果选中的资源包含 Git LFS 文件,还需要安装并配置 Git LFS; +5. 离线环境不需要 Git,但必须提前准备官方智能体目录或压缩包。 + +进入 Nexent 代码仓库根目录执行以下命令: + +```bash +cd nexent +``` + +`deploy/deploy-official-agents.sh` 会自动完成资源复制和容器内同步,用户不需要手工执行 `git clone`、`docker cp` 或调用同步接口。 + +## 三、官方资源目录结构 + +部署脚本按照“Profile → Agent Bundle”的结构扫描资源。每个官方智能体 Bundle 的根目录必须包含一个 `agent.json`: + +```text +official-agents/ +├── general/ +│ ├── document_writing_assistant/ +│ │ ├── agent.json +│ │ ├── knowledge_base/ +│ │ └── skills/ +│ └── official_test/ +│ └── agent.json +├── medical/ +│ └── medical_assistant/ +│ └── agent.json +└── finance/ + └── finance_assistant/ + └── agent.json +``` + +如果 Agent Hub 使用行业目录组织资源,例如: + +```text +AgentsHub/ +└── 行业智能体/ + ├── 医疗/ + └── 金融/ +``` + +则使用 `--profile-root "行业智能体"` 指定 Profile 根目录,并使用实际目录名称作为 `--profiles` 参数。 + +## 四、从 Agent Hub 在线部署 + +默认仓库地址为: + +```text +https://gitcode.com/ModelEngine/AgentsHub +``` + +默认分支为 `main`。例如,从 Hub 部署 `general` Profile: + +```bash +bash deploy/deploy-official-agents.sh \ + --source hub \ + --ref main \ + --profiles general +``` + +如果 Hub 的 Profile 位于中文目录下,例如 `行业智能体/金融`: + +```bash +bash deploy/deploy-official-agents.sh \ + --source hub \ + --ref main \ + --profile-root "行业智能体" \ + --profiles "金融" +``` + +一次部署多个 Profile 时,用逗号分隔: + +```bash +bash deploy/deploy-official-agents.sh \ + --source hub \ + --ref main \ + --profile-root "行业智能体" \ + --profiles "医疗,金融" +``` + +### 4.1 使用其他 Hub 地址 + +可以通过环境变量覆盖默认仓库地址和分支: + +```bash +export OFFICIAL_AGENTS_REPO_URL="https://gitcode.com/ModelEngine/AgentsHub" +export OFFICIAL_AGENTS_REPO_REF="main" + +bash deploy/deploy-official-agents.sh \ + --source hub \ + --profiles general +``` + +脚本不会把 Git 凭证写入 Nexent 配置或日志。 + +### 4.2 Hub 下载范围 + +脚本不会把整个 Agent Hub 的工作区内容全部检出到部署目录: + +1. 先以浅克隆方式获取指定 ref 的仓库元数据; +2. 根据 `--profile-root` 和 `--profiles` 设置稀疏检出路径; +3. 只对选中的 Profile 执行 Git LFS 下载; +4. 将选中的 Profile 复制到 Nexent 官方资源目录。 + +因此,未选择的行业 Profile 不会被复制到 Nexent,也不会触发其 LFS 文件下载。 + +## 五、从本地目录部署 + +联网机器或交付包已经准备好资源目录时,可以直接使用本地目录: + +```bash +bash deploy/deploy-official-agents.sh \ + --source local \ + --path /opt/agentshub/official-agents \ + --profiles general +``` + +Windows Git Bash 可以使用 Windows 路径或 `/c` 路径: + +```bash +bash deploy/deploy-official-agents.sh \ + --source local \ + --path "C:/Users/HAN/PycharmProjects/nexent/official-agents" \ + --profiles general +``` + +也可以使用 Git Bash 风格路径: + +```bash +bash deploy/deploy-official-agents.sh \ + --source local \ + --path "/c/Users/HAN/PycharmProjects/nexent/official-agents" \ + --profiles general +``` + +如果本地目录下还有一层行业目录,需要指定 `--profile-root`: + +```bash +bash deploy/deploy-official-agents.sh \ + --source local \ + --path /opt/agentshub \ + --profile-root "行业智能体" \ + --profiles "金融" +``` + +## 六、从本地压缩包部署 + +本地源也可以是 `.zip` 或 tar 压缩包。脚本会自动解压到临时目录,校验压缩包路径安全性,然后按与本地目录相同的流程扫描: + +```bash +bash deploy/deploy-official-agents.sh \ + --source local \ + --path /opt/packages/official-agents.zip \ + --profiles medical +``` + +压缩包内的目录层级必须与 `--profile-root` 和 `--profiles` 参数匹配。例如,压缩包内容为 `行业智能体/金融/...` 时,应执行: + +```bash +bash deploy/deploy-official-agents.sh \ + --source local \ + --path /opt/packages/agents.zip \ + --profile-root "行业智能体" \ + --profiles "金融" +``` + +本地目录和本地压缩包在部署语义上等价,区别只在于资源交付形式。 + +## 七、交互式部署 + +不传 `--source` 或 `--profiles` 时,脚本会交互式询问资源来源和 Profile: + +```bash +bash deploy/deploy-official-agents.sh +``` + +脚本会依次完成: + +1. 选择 Agent Hub 或本地目录/压缩包; +2. 扫描可用 Profile; +3. 选择一个或多个 Profile; +4. 校验每个 Profile 至少包含一个 `agent.json`; +5. 复制选中的资源; +6. 调用容器内同步脚本。 + +## 八、Docker 部署流程 + +Docker 模式下,脚本的内部流程如下: + +```text +宿主机 deploy-official-agents.sh + │ + ├─ 从 Hub 获取资源,或读取本地目录/压缩包 + ├─ 只筛选用户选择的 Profile + ├─ 复制到 nexent-config:/mnt/nexent/official-agents/ + └─ docker exec nexent-config + curl -fsS -X POST --get \ + --data-urlencode "profiles=" \ + http://127.0.0.1:5010/repository/agent/internal/official/sync +``` + +官方资源最终存放在 `nexent-config` 容器的: + +```text +/mnt/nexent/official-agents/{profile}/{bundle}/ +``` + +同步脚本读取已挂载的 `agent.json`、Skill、MCP 和原始文档,并将官方智能体登记到官方智能体仓库。它不会执行 Git 操作,也不会在部署阶段创建租户知识库。 + +部署成功时,终端应显示类似结果: + +```text +Synchronized 3 official agent bundle(s) +Official Agent deployment completed for profiles: general +``` + +## 九、Kubernetes 部署 + +Kubernetes 环境使用相同的资源来源和 Profile 选择方式,并增加 `--kubernetes` 参数: + +```bash +bash deploy/deploy-official-agents.sh \ + --source local \ + --path /opt/agentshub/official-agents \ + --profiles general \ + --kubernetes \ + --namespace nexent +``` + +执行前请确认: + +- 当前 `kubectl` context 指向目标集群; +- `nexent-config` 对应的部署已就绪; +- 官方资源目录能够写入 Nexent 使用的工作目录或 PVC; +- `--namespace` 与 Nexent 实际命名空间一致。 + +## 十、部署完成后的用户安装 + +官方智能体同步完成后,用户可以在 **智能体仓库** 中复制官方智能体。 + +复制时: + +- 用户需要选择当前租户可用的语言模型; +- 带有官方文档知识库的智能体,在需要创建知识库时选择向量模型; +- 如果同租户已有同名 Skill 或知识库,用户可以选择复用或创建副本; +- 创建完成的 Agent 属于当前用户,可继续编辑; +- 官方模板删除不会删除已经复制到租户中的用户副本。 + +因此,部署官方资源不等于把官方 Agent 自动安装到所有用户或所有租户。部署只发布官方模板,具体安装由用户从智能体仓库发起。 + +## 十一、验证部署结果 + +### 11.1 检查 Docker 容器中的资源 + +```bash +docker exec nexent-config \ + find /mnt/nexent/official-agents -name agent.json -print +``` + +检查同步脚本输出: + +```bash +docker logs nexent-config --tail 200 +``` + +### 11.2 检查页面 + +登录 Nexent 后: + +1. 打开 **智能体仓库**; +2. 确认选中的 Profile 中的官方智能体出现; +3. 确认卡片显示“官方”标识; +4. 点击复制,确认可以看到模型选择和资源冲突处理选项。 + +## 十二、删除官方智能体 + +官方智能体删除功能仅用于删除官方模板,只有超级管理员可以执行。租户管理员、开发者和普通用户不能删除官方模板。 + +> ⚠️ **重要提示**:删除操作会移除官方智能体仓库条目、官方源 Agent 记录以及服务器上的官方 Bundle 文件。已经被用户复制到租户中的 Agent 副本不会被删除,也不会被回滚。 + +### 12.1 删除操作步骤 + +1. 使用超级管理员账号登录 Nexent。 +2. 进入 **资源管理** 页面。 +3. 打开 **智能体** 页签。 +4. 点击 **管理官方智能体**。 +5. 在官方智能体列表中找到要删除的模板。 +6. 点击该模板对应的 **删除** 按钮。 +7. 在确认弹窗中检查智能体名称和删除影响,确认后继续。 + +删除成功后,该官方智能体不会再出现在智能体仓库中,用户也不能继续从仓库复制新的实例。 + +### 12.2 删除范围 + +删除官方模板时,系统会清理以下内容: + +| 内容 | 是否删除 | 说明 | +| --- | :---: | --- | +| 官方智能体仓库条目 | ✅ | 不再对用户展示和提供复制 | +| 官方源 Agent | ✅ | 删除官方保留租户中的源 Agent | +| 官方 Bundle 文件 | ✅ | 删除配置的官方资源目录中的对应文件或目录 | +| 已复制到租户的 Agent | ❌ | 保留用户副本及其编辑内容 | +| 用户租户中的知识库、Skill、MCP | ❌ | 不删除用户复制时创建或复用的资源 | + +### 12.3 删除后的验证 + +删除后可以进行以下检查: + +1. 刷新 **智能体仓库**,确认对应官方模板不再显示; +2. 使用超级管理员返回 **管理官方智能体**,确认模板已从列表中移除; +3. 对 Docker 部署检查官方 Bundle 文件是否已清理: + + ```bash + docker exec nexent-config \ + find /mnt/nexent/official-agents -iname '**' -print + ``` + +4. 如果用户之前已经复制过该 Agent,进入对应租户的 **我的智能体**,确认用户副本仍然存在。 + +不要直接删除数据库记录或手工删除容器文件。管理页面会同时处理仓库记录、源 Agent 和 Bundle 文件,避免出现数据库和文件状态不一致。 + +## 十三、重新部署或更新 Profile + +更新 Agent Hub 中的官方资源后,可以重复执行相同命令: + +```bash +bash deploy/deploy-official-agents.sh \ + --source hub \ + --ref main \ + --profile-root "行业智能体" \ + --profiles "金融" +``` + +重复部署会更新官方仓库模板,不会覆盖用户已经复制到租户中的 Agent。需要删除官方模板时,应通过超级管理员的官方智能体管理功能执行,避免直接删除容器内资源造成数据库和文件不一致。 + +## 十四、常见问题 + +### 14.1 `profile not found` + +通常是 Profile 根目录层级不匹配。检查: + +- `--path` 是否指向包含 Profile 的目录; +- 是否需要增加 `--profile-root`; +- `--profiles` 是否使用实际目录名; +- 中文目录名是否使用引号包裹。 + +### 14.2 `Synchronized 0 official agent bundle(s)` + +检查选中的 Profile 下是否存在以 Bundle 为根目录的 `agent.json`。如果 `agent.json` 位于更深层目录,说明资源目录结构或 `--profile-root` 配置不正确。 + +### 14.3 `duplicate official agent bundle` + +说明同一个 Profile 扫描到了多个同名 Bundle,常见原因是上一次复制留下了嵌套目录。清理资源源目录中的重复 Bundle 后重新部署。脚本在复制选中 Profile 前会清理目标 Profile,避免旧的嵌套目录继续参与同步。 + +### 14.4 Git LFS 下载失败 + +如果提示 `smudge filter lfs failed`、`Access forbidden` 或 `project lfs not enabled`: + +1. 检查远端仓库是否启用了 Git LFS; +2. 检查当前账号是否有 LFS 对象访问权限; +3. 在有权限的机器上准备完整本地目录或压缩包; +4. 在目标环境改用 `--source local` 部署。 + +### 14.5 `Remote branch main not found` + +先检查远端实际分支: + +```bash +git ls-remote --heads https://gitcode.com/ModelEngine/AgentsHub +``` + +然后使用实际存在的 ref: + +```bash +bash deploy/deploy-official-agents.sh \ + --source hub \ + --ref main \ + --profiles general +``` + +### 14.6 页面没有显示官方智能体 + +依次检查: + +1. 脚本是否输出了大于 0 的同步数量; +2. `nexent-config` 中是否存在目标 Profile 的 `agent.json`; +3. 容器日志中是否有 Bundle 校验或数据库错误; +4. 浏览器是否使用 `Ctrl + F5` 强制刷新; +5. 是否登录到了正确的租户。 diff --git a/doc/docs/zh/quick-start/installation.md b/doc/docs/zh/quick-start/installation.md index 47c5dce095..c135611d3a 100644 --- a/doc/docs/zh/quick-start/installation.md +++ b/doc/docs/zh/quick-start/installation.md @@ -534,6 +534,74 @@ NORTHBOUND_EXTERNAL_URL=https://api.yourdomain.com/api > **重要**: URL 必须包含 `/api` 后缀,因为 Northbound 服务使用 FastAPI 的 `root_path="/api"` 配置。 +## 🤖 官方智能体部署 + +Nexent 主平台安装完成后,可以从部署包中的官方智能体资源目录安装指定行业的官方智能体。官方智能体部署与 Nexent 主平台部署解耦,不会在平台启动时自动安装。 + +### 部署前提 + +执行前请确认: + +1. Nexent 已完成 Docker 或 Kubernetes 部署; +2. `nexent-config` 服务已启动并完成数据库初始化; +3. 部署包中存在 `deploy/official-agents` 目录; +4. Docker 环境使用 Git Bash 或 WSL 执行脚本;Kubernetes 环境使用能够访问目标集群的终端执行脚本。 + +官方智能体资源不需要额外配置环境变量,也不需要用户手工执行 Git、`docker cp` 或同步接口。 + +### 交互式部署 + +在 Nexent 仓库根目录执行: + +```bash +bash deploy/deploy-official-agents.sh +``` + +脚本会扫描 `deploy/official-agents` 下实际存在的一级目录,并将这些目录作为可选 Profile 显示。可以输入单个 Profile、多个 Profile(例如 `2,3`),或输入 `all` 安装全部 Profile。脚本会校验目录和 `agent.json`,选择无效时不会执行复制或同步。 + +Docker 部署时,脚本会将选中的 Profile 复制到 `nexent-config` 容器的 `/mnt/nexent/official-agents/`,然后同步到官方智能体仓库。 + +Kubernetes 环境执行: + +```bash +bash deploy/deploy-official-agents.sh --kubernetes +``` + +如需指定命名空间: + +```bash +bash deploy/deploy-official-agents.sh \ + --kubernetes \ + --namespace custom-namespace +``` + +### 部署后的使用方式 + +部署完成后,用户可以在 **智能体仓库** 的官方列表中复制智能体。复制时可以根据页面提示选择模型,并选择复用或创建知识库、Skill 和 MCP 配置。 + +官方智能体部署阶段只发布模板,不会为所有租户自动创建知识库。包含知识库的官方智能体在首次复制时,要求目标租户配置可用的向量模型。 + +### 部署验证 + +Docker 环境可以查看已复制的资源: + +```bash +docker exec nexent-config find /mnt/nexent/official-agents -name agent.json -print +``` + +登录 Nexent 后,打开 **智能体仓库**,切换到官方智能体列表,确认所选 Profile 中的模板已经出现。 + +### 删除官方智能体 + +删除官方模板需要超级管理员权限: + +1. 进入 **资源管理**; +2. 打开 **智能体** 页面; +3. 点击 **管理官方智能体**; +4. 选择需要删除的官方模板并确认。 + +删除操作会删除官方智能体仓库条目、system 租户中的官方源 Agent 和服务器上的对应 Bundle 文件;已经复制到普通租户中的 Agent 副本不会被删除。 + ## 💡 需要帮助 - 浏览 [常见问题](./faq) 了解常见安装问题 diff --git a/doc/docs/zh/user-guide/agent-development/a2a-external.md b/doc/docs/zh/user-guide/agent-development/a2a-external.md index 0a21bcc210..26e4f10788 100644 --- a/doc/docs/zh/user-guide/agent-development/a2a-external.md +++ b/doc/docs/zh/user-guide/agent-development/a2a-external.md @@ -97,4 +97,3 @@ Nexent 提供两种发现外部 A2A Agent 的方式:**URL 发现** 和 **Nacos 2. 在 Nexent 中选择「URL 发现」,填写 `http://:9999/.well-known/agent-card.json`,点击「发现」 3. 发现成功后,在「协议配置」中选择 **HTTP + JSON**,即可开始调用 - diff --git a/doc/docs/zh/user-guide/agent-development/agent-configuration.md b/doc/docs/zh/user-guide/agent-development/agent-configuration.md index 48c5a0e4f7..73415ae004 100644 --- a/doc/docs/zh/user-guide/agent-development/agent-configuration.md +++ b/doc/docs/zh/user-guide/agent-development/agent-configuration.md @@ -72,6 +72,42 @@ Nexent 支持通过 URL 或 Nacos 发现第三方 A2A Agent,再将其添加为 在“选择智能体的工具”页签中点击“MCP 配置”,可以接入远程 MCP、容器化 MCP,或将已有 API 转换为 MCP 工具。有关三种接入方式、OpenAPI 要求、服务管理和工具测试,请参阅 [MCP 服务接入](../../integration/integration-in/mcp.md)。 +### 🔐 向工具透传用户信息(工具侧鉴权) + +对于 MCP 工具,只有工具声明了下列约定字段,平台才会注入**当前调用者的用户信息**;外部 A2A Agent 则通过 `metadata.user_context` 接收同一份可信身份快照。 + +🔔 **平台边界**:平台本身不对工具侧做鉴权,只透传认证会话中的用户身份;鉴权由工具自行完成。 + +**声明方式**:工具的输入参数 Schema 中定义了以下任意约定字段名,即视为需要该用户信息,平台会在调用时自动以当前用户的值填充: + +| 约定字段名 | 含义 | +|-----------|------| +| `tenant_id` | 租户 ID | +| `tenant_name` | 租户名 | +| `user_id` | 用户 ID | +| `user_name` | 登录名(当前为用户邮箱) | +| `user_account` | 用户账号(邮箱) | +| `user_groups` | 用户所属用户组名列表 | + +**示例**:某数据查询工具需要按调用者账号和用户组做数据权限控制,在其 inputSchema 中声明 `user_account` 与 `user_groups` 两个参数即可: + +```json +{ + "type": "object", + "properties": { + "query": { "type": "string", "description": "查询内容" }, + "user_account": { "type": "string", "description": "调用者账号(平台自动注入)" }, + "user_groups": { "type": "array", "items": { "type": "string" }, "description": "调用者所属用户组(平台自动注入)" } + } +} +``` + +> 💡 **说明**: +> +> - 这些约定字段会从模型可见的工具签名和工具上下文说明中移除:模型不知道它们的存在、不会为其填值,注入值只来自当前登录会话,无法被伪造 +> - 未声明的约定字段不会注入,不影响工具的既有参数 +> - 外部 A2A Agent 从 `metadata.user_context` 接收可信身份;普通对话 metadata 中的同名字段会被忽略 + ### ⚙️ 自定义工具 您可参考以下指导文档,开发自己的工具,并接入 Nexent 使用,丰富智能体能力。 diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json deleted file mode 100644 index e6ee73d8c1..0000000000 --- a/frontend/.eslintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript", "prettier"], - "plugins": ["prettier"], - "rules": { - "prettier/prettier": "error" - } -} diff --git a/frontend/app/[locale]/agent-space/agent-space.tsx b/frontend/app/[locale]/agent-space/agent-space.tsx new file mode 100644 index 0000000000..20a82ed874 --- /dev/null +++ b/frontend/app/[locale]/agent-space/agent-space.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MenuProps } from "antd"; +import { App, Button, Dropdown, Empty, Grid, Input, Modal, Spin } from "antd"; +import { + Copy, + Download, + MoreHorizontal, + PackageX, + Search, + ShieldCheck, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { USER_ROLES } from "@/const/auth"; +import { useTagLibraries, useTagDefinitions } from "@/hooks/useTagManagement"; +import { getTagSearchPredicates } from "@/lib/systemTagLabels"; + +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import ResourceCard from "@/components/resource/ResourceCard"; +import { AgentDetail } from "@/components/agent/agent-detail"; +import TagFilterPopover from "@/components/tag/TagFilterPopover"; +import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; +import type { TagResourcePredicate } from "@/types/tagManagement"; +import type { AgentRepositoryListingItem } from "@/types/agentRepository"; +import { AgentRepositoryCopyDialog } from "./components/AgentRepositoryCopyDialog"; +import { RepositoryAgentIcon } from "./components/RepositoryAgentIcon"; +import { + useAgentRepositoryListings, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { useRepositoryAgentDetail } from "@/hooks/agentRepository/useRepositoryAgentDetail"; + +const CARD_GAP = 20; +const MIN_CARD_HEIGHT = 240; +const PAGINATION_HEIGHT = 60; + +export function AgentSpace({ active }: { active: boolean }) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { user } = useAuthorizationContext(); + const showAdminMenu = user?.role === USER_ROLES.ADMIN; + const screens = Grid.useBreakpoint(); + const gridRegionRef = useRef(null); + const [availableGridHeight, setAvailableGridHeight] = useState( + null + ); + const columns = screens.xxl + ? 4 + : screens.xl + ? 3 + : screens.lg || screens.md || screens.sm + ? 2 + : screens.xs + ? 1 + : 4; + const pageBottomPadding = screens.sm ? 40 : 32; + const rows = getRowCount( + Math.max(0, (availableGridHeight ?? 0) - PAGINATION_HEIGHT) + ); + const pageSize = columns * rows; + const measureGridHeight = useCallback(() => { + if (!active || !gridRegionRef.current) return; + + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const { top } = gridRegionRef.current.getBoundingClientRect(); + setAvailableGridHeight( + Math.max(0, Math.floor(viewportHeight - top - pageBottomPadding - 8)) + ); + }, [active, pageBottomPadding]); + + useEffect(() => { + if (!active) return; + + const frame = window.requestAnimationFrame(measureGridHeight); + const observer = new ResizeObserver(measureGridHeight); + const visualViewport = window.visualViewport; + if (gridRegionRef.current) observer.observe(gridRegionRef.current); + window.addEventListener("resize", measureGridHeight); + visualViewport?.addEventListener("resize", measureGridHeight); + + return () => { + window.cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", measureGridHeight); + visualViewport?.removeEventListener("resize", measureGridHeight); + }; + }, [active, measureGridHeight]); + const [searchQuery, setSearchQuery] = useState(""); + const [tagPredicates, setTagPredicates] = useState( + [] + ); + const [page, setPage] = useState(1); + const { data: tagLibraries } = useTagLibraries(); + const defaultTagLibrary = + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: tagDefinitions } = useTagDefinitions( + defaultTagLibrary?.bucket_id ?? null + ); + const searchTagPredicates = useMemo( + () => getTagSearchPredicates(tagDefinitions, searchQuery, t), + [tagDefinitions, searchQuery, t] + ); + const listingParams = useMemo( + () => ({ + status: "shared" as const, + page, + page_size: pageSize, + ...(searchQuery.trim() ? { search: searchQuery.trim() } : {}), + ...(searchTagPredicates.length > 0 + ? { search_tag_predicates: searchTagPredicates } + : {}), + ...(tagPredicates.length > 0 ? { tag_predicates: tagPredicates } : {}), + }), + [page, pageSize, searchQuery, searchTagPredicates, tagPredicates] + ); + const { data, isLoading, isError, isFetching, refetch } = + useAgentRepositoryListings(listingParams, active); + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + const listings = data?.items ?? []; + const total = data?.pagination?.total ?? 0; + const gridHeight = + availableGridHeight === null + ? undefined + : Math.max(0, availableGridHeight - (total > 0 ? PAGINATION_HEIGHT : 0)); + const cardHeight = + gridHeight === undefined + ? undefined + : Math.max(0, (gridHeight - CARD_GAP * (rows - 1)) / rows); + const descriptionLines = + cardHeight === undefined || cardHeight >= 320 + ? 3 + : cardHeight >= 260 + ? 2 + : 1; + const updatingRepositoryId = updateStatusMutation.isPending + ? (updateStatusMutation.variables?.agentRepositoryId ?? null) + : null; + const onSearchChange = (value: string) => { + setSearchQuery(value); + setPage(1); + }; + const onTagPredicatesChange = (value: TagResourcePredicate[]) => { + setTagPredicates(value); + setPage(1); + }; + const onTakeDown = (listing: AgentRepositoryListingItem) => + updateStatusMutation.mutateAsync({ + agentRepositoryId: listing.agent_repository_id, + status: "not_shared", + }); + const [copyListing, setCopyListing] = + useState(null); + const [detailListing, setDetailListing] = + useState(null); + const { + detail, + repositoryDetail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + retry: refetchDetail, + } = useRepositoryAgentDetail(detailListing, active); + const confirmTakeDown = (listing: AgentRepositoryListingItem) => { + const title = + listing.display_name?.trim() || + listing.name?.trim() || + t("agentRepository.card.untitled"); + + Modal.confirm({ + title: t("repository.listingStatus.confirmTakeDownTitle"), + content: t("repository.listingStatus.confirmTakeDownContent", { + name: title, + }), + okText: t("repository.listingStatus.takeDown"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await onTakeDown(listing); + message.success(t("repository.mine.takeDownSuccess")); + } catch { + message.error(t("repository.mine.takeDownError")); + throw new Error("Take down failed"); + } + }, + }); + }; + + const renderListing = (listing: AgentRepositoryListingItem) => { + const title = + listing.display_name?.trim() || + listing.name?.trim() || + t("agentRepository.card.untitled"); + const author = listing.author?.trim(); + const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; + const toolCount = listing.tool_count ?? 0; + const downloads = listing.downloads ?? 0; + const isTakingDown = updatingRepositoryId === listing.agent_repository_id; + const isOfficialListing = listing.is_official === true; + // Official templates are managed from the super-admin resource page. + // The ordinary repository status menu must not expose take-down actions + // for official listings to tenant administrators. + const canManageListing = showAdminMenu && !listing.is_official; + const menuItems: MenuProps["items"] = canManageListing + ? [ + { + key: "takeDown", + label: t("repository.listingStatus.takeDown"), + icon: , + danger: true, + disabled: isTakingDown, + onClick: () => confirmTakeDown(listing), + }, + ] + : []; + + return ( + setDetailListing(listing)} + descriptionLines={descriptionLines} + icon={ +
+ +
+ } + description={ + listing.description?.trim() || t("agentRepository.card.noDescription") + } + badge={ + isOfficialListing || listing.version_label ? ( + <> + {isOfficialListing ? ( + + + {t("agentRepository.card.official")} + + ) : null} + {listing.version_label ? ( + + + + {t("agentRepository.mine.currentVersion", { + version: listing.version_label, + })} + + + ) : null} + + ) : undefined + } + tags={ + tags.length > 0 || toolCount > 0 ? ( + <> + {tags.map((tag) => ( + + {getAgentRepositoryTagLabel(tag, t)} + + ))} + {toolCount > 0 ? ( + + {t("agentRepository.card.toolCount", { count: toolCount })} + + ) : null} + + ) : undefined + } + footerLayout="inline" + meta={author ? {author} : undefined} + headerActions={ +
+ + + {downloads.toLocaleString()} + + {canManageListing ? ( + +
+ } + footer={ + + } + /> + ); + }; + + return ( +
+
+
+ onSearchChange(e.target.value)} + placeholder={t("agentRepository.page.searchPlaceholder")} + prefix={} + className="rounded-xl" + allowClear + /> +
+ +
+
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ {t("agentRepository.page.loadError")} +

+ +
+ ) : ( + + } + renderItem={renderListing} + /> + )} +
+ setDetailListing(null)} + detail={detail} + agentIcon={ + detailListing ? ( + + ) : undefined + } + published + status={repositoryDetail?.status} + showRepositoryInfo + isLoading={isDetailLoading} + isError={isDetailError} + isFetching={isDetailFetching} + onRetry={refetchDetail} + /> + { + if (!open) setCopyListing(null); + }} + /> +
+ ); +} + +function getRowCount(availableHeight: number) { + if (availableHeight <= 0) return 3; + return Math.min( + 3, + Math.max( + 1, + Math.floor((availableHeight + CARD_GAP) / (MIN_CARD_HEIGHT + CARD_GAP)) + ) + ); +} diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx deleted file mode 100644 index 2916726329..0000000000 --- a/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx +++ /dev/null @@ -1,165 +0,0 @@ -"use client"; - -import type { MenuProps } from "antd"; -import { Button, Card, Dropdown } from "antd"; -import { Bot, Copy, Download, Eye, MoreHorizontal, PackageX } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; -import type { AgentRepositoryListingItem } from "@/types/agentRepository"; - -interface AgentRepositoryCardProps { - listing: AgentRepositoryListingItem; - showAdminMenu?: boolean; - isTakingDown?: boolean; - onCopyClick?: (listing: AgentRepositoryListingItem) => void; - onDetailClick?: (listing: AgentRepositoryListingItem) => void; - onTakeDown?: (listing: AgentRepositoryListingItem) => void; -} - -export function AgentRepositoryCard({ - listing, - showAdminMenu = false, - isTakingDown = false, - onCopyClick, - onDetailClick, - onTakeDown, -}: AgentRepositoryCardProps) { - const { t } = useTranslation("common"); - - const title = - listing.display_name?.trim() || listing.name?.trim() || t("agentRepository.card.untitled"); - const author = listing.author?.trim(); - const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; - const toolCount = listing.tool_count ?? 0; - const versionText = listing.version_label; - const downloads = listing.downloads ?? 0; - const showTagsRow = tags.length > 0 || toolCount > 0; - const showMenu = showAdminMenu && onTakeDown != null; - - const menuItems: MenuProps["items"] = showMenu - ? [ - { - key: "takeDown", - label: t("repository.listingStatus.takeDown"), - icon: , - danger: true, - disabled: isTakingDown, - onClick: () => onTakeDown(listing), - }, - ] - : []; - - return ( - -
-
-
- {listing.icon?.trim() ? ( - {listing.icon.trim()} - ) : ( - - )} -
-
-

- {title} -

- {author ? ( -

- {author} -

- ) : null} -
-
- {showMenu ? ( - -
- -

- {listing.description?.trim() || t("agentRepository.card.noDescription")} -

- - {showTagsRow ? ( -
- {tags.map((tag) => ( - - {getAgentRepositoryTagLabel(tag, t)} - - ))} - {toolCount > 0 ? ( - - {t("agentRepository.card.toolCount", { count: toolCount })} - - ) : null} -
- ) : null} - -
-
- {versionText ? ( - - - {versionText} - - ) : ( - - )} -
- - - {downloads.toLocaleString()} - -
-
- -
- - -
-
-
- ); -} diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx index d70410c6fc..2e9bdaed38 100644 --- a/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; -import { App, Button, Modal, Radio, Space, Spin, Tag } from "antd"; +import { useEffect, useMemo, useState } from "react"; +import { App, Button, Modal, Radio, Select, Space, Spin, Tag } from "antd"; import { AlertCircle, CheckCircle2, @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; +import { useModelList } from "@/hooks/model/useModelList"; import { useImportAgentFromRepository, useRepositoryImportPrecheck, @@ -36,10 +37,7 @@ import type { RepositoryImportRequirementType, } from "@/types/agentRepository"; -const TYPE_ICON: Record< - RepositoryImportRequirementType, - typeof Database -> = { +const TYPE_ICON: Record = { model: Cpu, knowledge_base: Database, mcp: Plug, @@ -47,6 +45,8 @@ const TYPE_ICON: Record< tool: Wrench, }; +const SYSTEM_TENANT_ID = "system"; + interface AgentRepositoryCopyDialogProps { listing: AgentRepositoryListingItem | null; open: boolean; @@ -79,13 +79,24 @@ export function AgentRepositoryCopyDialog({ const [warningDismissed, setWarningDismissed] = useState(false); const [abnormalOpen, setAbnormalOpen] = useState(true); const [availableOpen, setAvailableOpen] = useState(true); - const [skillResolutionActions, setSkillResolutionActions] = useState>({}); + const [skillResolutionActions, setSkillResolutionActions] = useState< + Record + >({}); + const [knowledgeResolutionActions, setKnowledgeResolutionActions] = useState< + Record + >({}); + const [selectedModelId, setSelectedModelId] = useState(); + const [selectedEmbeddingModelId, setSelectedEmbeddingModelId] = + useState(); const agentRepositoryId = listing?.agent_repository_id ?? null; const listingTitle = listing?.display_name?.trim() || listing?.name?.trim() || t("agentRepository.card.untitled"); + const isOfficialListing = + listing?.is_official === true || + listing?.publisher_tenant_id === SYSTEM_TENANT_ID; const { data: precheck, @@ -96,21 +107,95 @@ export function AgentRepositoryCopyDialog({ } = useRepositoryImportPrecheck(agentRepositoryId, open); const importMutation = useImportAgentFromRepository(); + const { availableLlmModels, models: tenantModels } = useModelList({ + enabled: open, + }); const abnormalItems = useMemo( - () => precheck?.items.filter((item) => !item.available) ?? [], + () => + precheck?.items.filter( + (item) => + !item.available || + (item.type === "knowledge_base" && item.resolution_required) + ) ?? [], [precheck] ); const availableItems = useMemo( - () => precheck?.items.filter((item) => item.available) ?? [], + () => + precheck?.items.filter( + (item) => + item.available && + !(item.type === "knowledge_base" && item.resolution_required) + ) ?? [], [precheck] ); const skillConflictItems = useMemo( - () => abnormalItems.filter((item) => item.type === "skill" && item.reason_code === "skill_duplicate"), - [abnormalItems] + () => + precheck?.items.filter( + (item) => + item.type === "skill" && item.reason_code === "skill_duplicate" + ) ?? [], + [precheck] ); const hasSkillConflicts = skillConflictItems.length > 0; + const officialKnowledgeItems = useMemo( + () => + precheck?.items.filter((item) => item.type === "knowledge_base") ?? [], + [precheck] + ); + const hasOfficialKnowledge = + isOfficialListing && officialKnowledgeItems.length > 0; + const hasExistingOfficialKnowledge = Boolean( + hasOfficialKnowledge && + officialKnowledgeItems.every((item) => item.resolution_required === true) + ); + const officialKnowledgeConflictItems = useMemo( + () => officialKnowledgeItems.filter((item) => item.resolution_required), + [officialKnowledgeItems] + ); + const hasOfficialKnowledgeToCreate = Boolean( + hasOfficialKnowledge && + (officialKnowledgeItems.some((item) => !item.resolution_required) || + officialKnowledgeConflictItems.some( + (item) => knowledgeResolutionActions[item.name] === "create_new" + )) + ); + const availableEmbeddingModels = useMemo( + () => + tenantModels.filter( + (model) => + (model.type === "embedding" || model.type === "multi_embedding") && + model.connect_status === "available" + ), + [tenantModels] + ); + const officialEmbeddingModelMissing = Boolean( + isOfficialListing && + hasOfficialKnowledge && + hasOfficialKnowledgeToCreate && + availableEmbeddingModels.length === 0 + ); + + useEffect(() => { + if (!open || !isOfficialListing) return; + setSelectedModelId((current) => current ?? availableLlmModels[0]?.id); + if (hasOfficialKnowledgeToCreate) { + setSelectedEmbeddingModelId( + (current) => current ?? availableEmbeddingModels[0]?.id + ); + } else if (hasExistingOfficialKnowledge) { + setSelectedEmbeddingModelId(undefined); + } + }, [ + open, + isOfficialListing, + hasOfficialKnowledge, + hasExistingOfficialKnowledge, + hasOfficialKnowledgeToCreate, + availableLlmModels, + availableEmbeddingModels, + ]); const percent = precheck?.percent ?? 0; const hasAbnormal = precheck?.has_abnormal ?? false; @@ -131,17 +216,55 @@ export function AgentRepositoryCopyDialog({ const skillResolutions = hasSkillConflicts ? skillConflictItems.map((item) => ({ skill_name: item.name, - action: (skillResolutionActions[item.name] ?? "rename") as "rename" | "use_existing", + action: (skillResolutionActions[item.name] ?? "rename") as + | "rename" + | "use_existing", ...(skillResolutionActions[item.name] !== "use_existing" ? { new_name: item.suggested_new_name || `${item.name} 副本` } : {}), })) : undefined; + const knowledgeBaseResolutions = + officialKnowledgeConflictItems.length > 0 + ? officialKnowledgeConflictItems.map((item) => ({ + knowledge_name: item.name, + action: knowledgeResolutionActions[item.name] ?? "reuse", + })) + : undefined; + + if ( + isOfficialListing && + (!selectedModelId || + (hasOfficialKnowledge && + hasOfficialKnowledgeToCreate && + !selectedEmbeddingModelId)) + ) { + message.error( + officialEmbeddingModelMissing + ? "当前租户未配置可用向量模型,请先配置后再复制官方智能体" + : "请先选择语言模型和向量模型" + ); + return; + } try { await importMutation.mutateAsync({ agentRepositoryId, skillResolutions, + modelOptions: isOfficialListing + ? { + modelIds: selectedModelId + ? { [listing.name]: selectedModelId } + : undefined, + embeddingModelIds: + hasOfficialKnowledge && + hasOfficialKnowledgeToCreate && + selectedEmbeddingModelId + ? { [listing.name]: selectedEmbeddingModelId } + : undefined, + knowledgeBaseResolutions, + } + : undefined, }); message.success( t("agentRepository.copy.success", { name: listingTitle }) @@ -180,6 +303,9 @@ export function AgentRepositoryCopyDialog({ setAbnormalOpen(true); setAvailableOpen(true); setSkillResolutionActions({}); + setKnowledgeResolutionActions({}); + setSelectedModelId(undefined); + setSelectedEmbeddingModelId(undefined); }; return ( @@ -197,7 +323,9 @@ export function AgentRepositoryCopyDialog({ type="primary" icon={} loading={importMutation.isPending} - disabled={!precheck || isLoading || isError} + disabled={ + !precheck || isLoading || isError || officialEmbeddingModelMissing + } onClick={handleCopy} > {t("agentRepository.card.copy")} @@ -288,10 +416,16 @@ export function AgentRepositoryCopyDialog({ {hasSkillConflicts ? (

- {t("agentRepository.copy.skillDuplicate.title", "Skill Name Conflict Detected")} + {t( + "agentRepository.copy.skillDuplicate.title", + "Skill Name Conflict Detected" + )}

- {t("agentRepository.copy.skillDuplicate.message", "Choose how to handle each conflicting skill:")} + {t( + "agentRepository.copy.skillDuplicate.message", + "Choose how to handle each conflicting skill:" + )}

{skillConflictItems.map((item) => ( @@ -313,16 +447,27 @@ export function AgentRepositoryCopyDialog({ > - {t("agentRepository.copy.skillDuplicate.rename", "Install as new skill")} + {t( + "agentRepository.copy.skillDuplicate.rename", + "Install as new skill" + )} - {t("agentRepository.copy.skillDuplicate.renameTarget", { - name: item.suggested_new_name || `${item.name} 副本`, - defaultValue: `New name: ${item.suggested_new_name || `${item.name} 副本`}`, - })} + {t( + "agentRepository.copy.skillDuplicate.renameTarget", + { + name: + item.suggested_new_name || + `${item.name} 副本`, + defaultValue: `New name: ${item.suggested_new_name || `${item.name} 副本`}`, + } + )} - {t("agentRepository.copy.skillDuplicate.useExisting", "Use existing local skill")} + {t( + "agentRepository.copy.skillDuplicate.useExisting", + "Use existing local skill" + )} @@ -332,6 +477,93 @@ export function AgentRepositoryCopyDialog({
) : null} + {isOfficialListing && officialKnowledgeConflictItems.length > 0 ? ( +
+

+ 检测到同名知识库,请选择处理方式 +

+

+ 请选择每个同名知识库的处理方式: +

+
+ {officialKnowledgeConflictItems.map((item) => ( +
+
+ {item.name} +
+ { + setKnowledgeResolutionActions((prev) => ({ + ...prev, + [item.name]: event.target.value, + })); + }} + > + + 复用已有知识库 + + 创建新的知识库 + + 新名称:{item.name} 副本 + + + + +
+ ))} +
+
+ ) : null} + + {isOfficialListing ? ( +
+

+ 选择本次安装使用的模型 +

+
+ + ({ + value: model.id, + label: model.displayName || model.name, + }))} + /> +
+ ) : null} + {officialEmbeddingModelMissing ? ( +

+ 当前租户未配置可用向量模型,请先配置后再复制官方智能体。 +

+ ) : null} +
+ ) : null} + {hasAbnormal ? (
- - ); -} - -function AgentRepositoryDetailIcon({ icon }: { icon?: string | null }) { - const trimmedIcon = icon?.trim(); - if (trimmedIcon) { - return {trimmedIcon}; - } - return ; -} - -function AgentRepositoryDetailMeta({ - detail, - downloads, - createdAtText, -}: { - detail: AgentDetailModalData; - downloads: number; - createdAtText: string | null; -}) { - const { t } = useTranslation("common"); - - return ( -
-
- {detail.model_name ? ( - - - {detail.model_name} - - ) : null} - {detail.version_label ? ( - - - {detail.version_label} - - ) : null} - - - {t("agentRepository.detail.downloads", { - count: downloads.toLocaleString(), - })} - - {createdAtText ? ( - - - {createdAtText} - - ) : null} -
- {detail.author?.trim() ? ( -
- - - {t("agentRepository.detail.author", { author: detail.author })} - -
- ) : null} -
- ); -} - -function AgentRepositoryDetailHeader({ detail }: { detail: AgentDetailModalData }) { - const { t } = useTranslation("common"); - const title = resolveDetailTitle(detail, t("agentRepository.card.untitled")); - const downloads = detail.downloads ?? 0; - const createdAtText = formatCreatedAt(detail.created_at); - - return ( -
-
-
- -
-
-
-

- {title} -

- {detail.status ? : null} -
- -
-
-
- ); -} - -function AgentRepositoryDetailTools({ tools }: { tools: string[] }) { - const { t } = useTranslation("common"); - - if (tools.length === 0) { - return null; - } - - return ( -
-

- - {t("agentRepository.detail.tools")} -

-
- {tools.map((tool) => ( - - {tool} - - ))} -
-
- ); -} - -function AgentRepositoryDetailDutyPrompt({ - dutyPrompt, -}: { - dutyPrompt?: string | null; -}) { - const { t } = useTranslation("common"); - const trimmedPrompt = dutyPrompt?.trim(); - - if (!trimmedPrompt) { - return null; - } - - return ( -
-

- {t("agentRepository.detail.role")} -

-
-        {trimmedPrompt}
-      
-
- ); -} - -function AgentRepositoryDetailContent({ detail }: { detail: AgentDetailModalData }) { - const { t } = useTranslation("common"); - const tools = detail.tools?.filter((tool) => tool.trim()) ?? []; - - return ( -
- -
-
-

- {t("agentRepository.detail.intro")} -

-

- {detail.description?.trim() || - t("agentRepository.card.noDescription")} -

-
- - -
-
- ); -} - -function resolveDetailModalBody({ - isLoading, - isError, - isFetching, - detail, - onRetry, -}: Pick< - AgentRepositoryDetailModalProps, - "isLoading" | "isError" | "isFetching" | "detail" | "onRetry" ->) { - if (isLoading) { - return ; - } - if (isError) { - return ( - - ); - } - if (!detail) { - return null; - } - return ; -} - -export function AgentRepositoryDetailModal({ - open, - onClose, - detail, - isLoading, - isError, - isFetching, - onRetry, -}: AgentRepositoryDetailModalProps) { - return ( - - {resolveDetailModalBody({ - isLoading, - isError, - isFetching, - detail, - onRetry, - })} - - ); -} diff --git a/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx b/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx new file mode 100644 index 0000000000..80eb6e7b99 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, App, Button, Modal, Spin, Tabs, Typography } from "antd"; +import { Copy, ExternalLink } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import A2AServerSettingsPanel from "../../agents/components/a2a/A2AServerSettingsPanel"; +import { + buildAgentShareUrl, + buildNorthboundDocsUrl, + buildNorthboundCurl, + buildNorthboundRunUrl, + buildUserApiKeyPath, + getAgentUsageGuideAccess, + getA2AGuideState, +} from "@/lib/agentUsageGuide"; +import { a2aClientService } from "@/services/a2aService"; +import { fetchPublishedAgentList } from "@/services/agentConfigService"; +import { configService } from "@/services/configService"; +import type { Agent } from "@/types/agentConfig"; +import type { MyEditableAgentItem } from "@/types/agentRepository"; + +interface AgentUsageGuideModalProps { + agent: MyEditableAgentItem | null; + locale: string; + open: boolean; + onClose: () => void; +} + +export function AgentUsageGuideModal({ + agent, + locale, + open, + onClose, +}: AgentUsageGuideModalProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const [activeTab, setActiveTab] = useState("share"); + const agentId = agent?.agent_id; + const agentName = agent?.name?.trim() || "agent"; + const { canOpen } = getAgentUsageGuideAccess({ + currentVersionNo: agent?.current_version_no, + }); + + useEffect(() => { + if (open) { + setActiveTab("share"); + } + }, [agentId, open]); + + const frontendConfigQuery = useQuery({ + queryKey: ["frontend-config"], + queryFn: () => configService.fetchRuntimeFrontendConfig(), + enabled: open && activeTab === "northbound", + }); + const a2aQuery = useQuery({ + queryKey: ["a2aServerSettings", agentId], + queryFn: () => a2aClientService.getServerSettings(agentId!), + enabled: open && activeTab === "a2a" && agentId != null, + }); + const publishedAgentsQuery = useQuery({ + queryKey: ["publishedAgentsList"], + queryFn: fetchPublishedAgentList, + enabled: open && activeTab === "northbound" && agentId != null, + }); + const publishedAgent = publishedAgentsQuery.data?.data?.find( + (candidate: Agent) => candidate.id === String(agentId) + ); + + const shareUrl = useMemo(() => { + if (!open || !canOpen || agentId == null || typeof window === "undefined") { + return ""; + } + return buildAgentShareUrl(window.location.origin, locale, agentId); + }, [agentId, canOpen, locale, open]); + const northboundUrl = buildNorthboundRunUrl( + frontendConfigQuery.data?.northboundBaseUrl, + typeof window === "undefined" ? undefined : window.location.origin + ); + const northboundCurl = buildNorthboundCurl( + publishedAgent?.name?.trim() || agentName, + northboundUrl + ); + const a2aGuideState = getA2AGuideState({ + isLoading: a2aQuery.isLoading, + isError: a2aQuery.isError, + isEnabled: Boolean( + a2aQuery.data?.success && a2aQuery.data.data?.is_enabled + ), + }); + const copy = async (value: string) => { + try { + await navigator.clipboard.writeText(value); + message.success(t("common.copied")); + } catch { + message.error(t("agentUsageGuide.copyFailed")); + } + }; + + return ( + +
+ + +
+ + {shareUrl} + +
+ + +
+
+
+ ) : ( + + ), + }, + { + key: "northbound", + label: t("agentUsageGuide.tabs.northbound"), + children: ( +
+
    +
  1. + + {t("agentUsageGuide.northbound.stepKey")} + +
  2. +
  3. {t("agentUsageGuide.northbound.stepCall")}
  4. +
  5. {t("agentUsageGuide.northbound.stepResult")}
  6. +
+ {frontendConfigQuery.isLoading ? ( + + ) : frontendConfigQuery.isError ? ( + + ) : ( +
+ +
+                        {northboundCurl}
+                      
+
+ )} + + +
+ ), + }, + { + key: "a2a", + label: t("agentUsageGuide.tabs.a2a"), + children: + a2aGuideState === "loading" ? ( + + ) : a2aGuideState === "error" ? ( + + ) : a2aGuideState === "enabled" ? ( + + ) : ( + + ), + }, + ]} + /> + +
+ ); +} diff --git a/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx b/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx index 6e52764c00..740cb3a5f5 100644 --- a/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx +++ b/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx @@ -1,28 +1,19 @@ "use client"; -import { Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; +import CreateResourceCard from "@/components/resource/CreateResourceCard"; + interface CreateNewAgentCardProps { onClick: () => void; } export function CreateNewAgentCard({ onClick }: CreateNewAgentCardProps) { const { t } = useTranslation("common"); - return ( - + /> ); } diff --git a/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx deleted file mode 100644 index 9764295167..0000000000 --- a/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx +++ /dev/null @@ -1,632 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { App, Button, Empty, Input, Popover, Spin } from "antd"; -import { ChevronLeft, ChevronRight, Plus, Search, Tag, Upload } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import AgentImportWizard from "@/components/agent/AgentImportWizard"; -import CreateAgentModal, { - type CreatedAgentResult, -} from "@/components/agent/CreateAgentModal"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { deleteAgent } from "@/services/agentConfigService"; -import { - AGENTS_LIST_QUERY_KEY, - invalidateAgentRepositoryCaches, - useCreateAgentRepositoryListing, - useUpdateAgentRepositoryStatus, -} from "@/hooks/agentRepository/useAgentRepositoryListings"; -import { - openImportWizardWithFile, - type ImportAgentData, -} from "@/lib/agentImportUtils"; -import log from "@/lib/logger"; -import { - isCancelableRepositoryStatus, - isTakeDownableRepositoryStatus, - findRepositoryInfoById, - pickReviewDisplayRepositoryInfo, - resolveReviewModalMode, -} from "@/lib/agentRepositoryMine"; -import { - isNewAgentPaddingItem, - type AgentRepositoryListingCreatePayload, - type MineOwnershipFilter, - type MyAgentRepositoryInfoItem, - type MyEditableAgentItem, - type MyEditableAgentListItem, - type MyEditableAgentOwnershipCounts, -} from "@/types/agentRepository"; -import { MineApplyListingModal } from "./MineApplyListingModal"; -import { MineReviewStatusModal } from "./MineReviewStatusModal"; -import { CreateNewAgentCard } from "./CreateNewAgentCard"; -import { MyAgentCard } from "./MyAgentCard"; -import TagFilterControls from "@/components/tag/TagFilterControls"; -import type { TagDefinition, TagResourcePredicate } from "@/types/tagManagement"; - -const MINE_OWNERSHIP_FILTERS: MineOwnershipFilter[] = [ - "all", - "created", - "others", -]; - -export interface ReviewDeepLinkTarget { - agentRepositoryId: number; - agentId: number; -} - -interface MineAgentsViewProps { - agents: MyEditableAgentListItem[]; - counts: MyEditableAgentOwnershipCounts; - ownership: MineOwnershipFilter; - onOwnershipChange: (ownership: MineOwnershipFilter) => void; - searchQuery: string; - onSearchChange: (value: string) => void; - tagDefinitions: TagDefinition[]; - tagPredicates: TagResourcePredicate[]; - onTagPredicatesChange: (predicates: TagResourcePredicate[]) => void; - page: number; - pageSize: number; - total: number; - onPageChange: (page: number) => void; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - onRetry: () => void; - onViewDetail: (agentId: number, versionNo: number) => void; - reviewDeepLink?: ReviewDeepLinkTarget | null; - deepLinkFallbackAgent?: MyEditableAgentItem | null; - deepLinkFallbackLoading?: boolean; - onReviewDeepLinkConsumed?: () => void; -} - -export function MineAgentsView({ - agents, - counts, - ownership, - onOwnershipChange, - searchQuery, - onSearchChange, - tagDefinitions, - tagPredicates, - onTagPredicatesChange, - page, - pageSize, - total, - onPageChange, - isLoading, - isError, - isFetching, - onRetry, - onViewDetail, - reviewDeepLink = null, - deepLinkFallbackAgent = null, - deepLinkFallbackLoading = false, - onReviewDeepLinkConsumed, -}: MineAgentsViewProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const { confirm } = useConfirmModal(); - const router = useRouter(); - const queryClient = useQueryClient(); - const params = useParams<{ locale: string }>(); - const locale = params.locale || "en"; - const [importWizardVisible, setImportWizardVisible] = useState(false); - const [importWizardData, setImportWizardData] = - useState(null); - const [createAgentModalVisible, setCreateAgentModalVisible] = useState(false); - const [reviewModalOpen, setReviewModalOpen] = useState(false); - const [reviewModalAgent, setReviewModalAgent] = - useState(null); - const [reviewModalInfo, setReviewModalInfo] = - useState(null); - const [reviewModalMode, setReviewModalMode] = useState< - "review" | "reviewUpdate" - >("review"); - const [applyingAgentId, setApplyingAgentId] = useState(null); - const [applyModalOpen, setApplyModalOpen] = useState(false); - const [applyModalAgent, setApplyModalAgent] = - useState(null); - const consumedDeepLinkRef = useRef(null); - - const createListingMutation = useCreateAgentRepositoryListing(); - const updateStatusMutation = useUpdateAgentRepositoryStatus(); - const deleteAgentMutation = useMutation({ - mutationFn: (agentId: number) => deleteAgent(agentId), - }); - - const normalizedQuery = searchQuery.trim().toLowerCase(); - - const handleCreateAgent = () => { - setCreateAgentModalVisible(true); - }; - - const handleAgentCreated = async ({ agentId }: CreatedAgentResult) => { - setCreateAgentModalVisible(false); - await Promise.all([ - invalidateAgentRepositoryCaches(queryClient), - queryClient.invalidateQueries({ queryKey: [AGENTS_LIST_QUERY_KEY] }), - ]); - router.push(`/${locale}/agents?agent_id=${agentId}`); - }; - - const handleImportAgent = async () => { - await openImportWizardWithFile({ - onSuccess: (agentData) => { - setImportWizardData(agentData); - setImportWizardVisible(true); - }, - message: message, - t: t, - log: log, - }); - }; - - const handleEdit = ( - agentId: number, - permission?: MyEditableAgentItem["permission"] - ) => { - if (permission === "READ_ONLY") { - return; - } - router.push( - `/${locale}/agents?agent_id=${agentId}&from=agent-space&tab=mine` - ); - }; - - const handleDeleteAgent = (agent: MyEditableAgentItem) => { - const name = agent.name?.trim() || t("agentRepository.card.untitled"); - confirm({ - title: t("businessLogic.config.modal.deleteTitle"), - content: t("businessLogic.config.modal.deleteContent", { name }), - onOk: async () => { - try { - const result = await deleteAgentMutation.mutateAsync(agent.agent_id); - if (!result.success) { - throw new Error(result.message || "delete failed"); - } - message.success( - t("businessLogic.config.error.agentDeleteSuccess", { name }) - ); - await Promise.all([ - invalidateAgentRepositoryCaches(queryClient), - queryClient.invalidateQueries({ - queryKey: [AGENTS_LIST_QUERY_KEY], - }), - ]); - } catch (error) { - log.error("Failed to delete agent:", error); - message.error(t("businessLogic.config.error.agentDeleteFailed")); - throw error; - } - }, - }); - }; - - const handleEvaluate = (agent: MyEditableAgentItem) => { - const versionNo = agent.current_version_no ?? 0; - if (versionNo <= 0) { - return; - } - router.push(`/${locale}/evaluation?agent_id=${agent.agent_id}`); - }; - - const closeReviewModal = () => { - setReviewModalOpen(false); - setReviewModalAgent(null); - setReviewModalInfo(null); - }; - - const handleApplyListing = (agent: MyEditableAgentItem) => { - const versionNo = agent.current_version_no ?? 0; - if (versionNo <= 0) { - return; - } - setApplyModalAgent(agent); - setApplyModalOpen(true); - }; - - const closeApplyModal = () => { - setApplyModalOpen(false); - setApplyModalAgent(null); - }; - - const handleSubmitApplyListing = async ( - payload: AgentRepositoryListingCreatePayload - ) => { - if (!applyModalAgent) { - return; - } - - const versionNo = applyModalAgent.current_version_no ?? 0; - if (versionNo <= 0) { - return; - } - - setApplyingAgentId(applyModalAgent.agent_id); - try { - await createListingMutation.mutateAsync({ - agentId: applyModalAgent.agent_id, - versionNo, - payload, - }); - message.success(t("repository.mine.applySuccess")); - closeApplyModal(); - } catch { - message.error(t("repository.mine.applyError")); - } finally { - setApplyingAgentId(null); - } - }; - - const handleViewReview = ( - agent: MyEditableAgentItem, - mode: "review" | "reviewUpdate" - ) => { - const repositoryInfo = pickReviewDisplayRepositoryInfo( - agent.repository_info ?? [] - ); - if (!repositoryInfo) { - return; - } - openReviewModal(agent, repositoryInfo, mode); - }; - - const openReviewModal = ( - agent: MyEditableAgentItem, - repositoryInfo: MyAgentRepositoryInfoItem, - mode: "review" | "reviewUpdate" - ) => { - setReviewModalAgent(agent); - setReviewModalInfo(repositoryInfo); - setReviewModalMode(mode); - setReviewModalOpen(true); - }; - - useEffect(() => { - if (!reviewDeepLink) { - consumedDeepLinkRef.current = null; - return; - } - - if (consumedDeepLinkRef.current === reviewDeepLink.agentRepositoryId) { - return; - } - - const listStillLoading = isLoading; - const fallbackStillLoading = deepLinkFallbackLoading; - if (listStillLoading && fallbackStillLoading) { - return; - } - - const agentFromList = agents.find( - (item): item is MyEditableAgentItem => - !isNewAgentPaddingItem(item) && item.agent_id === reviewDeepLink.agentId - ); - const agent = agentFromList ?? deepLinkFallbackAgent; - - if (!agent) { - if (listStillLoading || fallbackStillLoading) { - return; - } - message.error(t("notifications.deepLink.agentNotFound")); - consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; - onReviewDeepLinkConsumed?.(); - return; - } - - const repositoryInfo = findRepositoryInfoById( - agent.repository_info ?? [], - reviewDeepLink.agentRepositoryId - ); - - if (!repositoryInfo) { - message.error(t("notifications.deepLink.agentNotFound")); - consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; - onReviewDeepLinkConsumed?.(); - return; - } - - openReviewModal( - agent, - repositoryInfo, - resolveReviewModalMode(agent, repositoryInfo) - ); - consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; - onReviewDeepLinkConsumed?.(); - }, [ - agents, - deepLinkFallbackAgent, - deepLinkFallbackLoading, - isLoading, - onReviewDeepLinkConsumed, - reviewDeepLink, - t, - ]); - - const handleSetNotShared = async () => { - if (!reviewModalInfo) { - return; - } - - const canUpdate = - isCancelableRepositoryStatus(reviewModalInfo.status) || - isTakeDownableRepositoryStatus(reviewModalInfo.status); - if (!canUpdate) { - return; - } - - const wasShared = reviewModalInfo.status === "shared"; - - try { - await updateStatusMutation.mutateAsync({ - agentRepositoryId: reviewModalInfo.agent_repository_id, - status: "not_shared", - }); - message.success( - wasShared - ? t("repository.mine.takeDownSuccess") - : t("repository.mine.cancelApplySuccess") - ); - closeReviewModal(); - } catch { - message.error( - wasShared - ? t("repository.mine.takeDownError") - : t("repository.mine.cancelApplyError") - ); - throw new Error("Update repository status failed"); - } - }; - - const ownershipLabelKey: Record = { - all: "repository.mine.filter.all", - created: "repository.mine.filter.created", - others: "repository.mine.filter.others", - }; - - const hasActiveFilter = - ownership !== "all" || normalizedQuery.length > 0 || tagPredicates.length > 0; - const showFilteredEmpty = !isLoading && !isError && agents.length === 0; - const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; - const showPagination = !isLoading && !isError && totalPages > 1; - - return ( -
-
-
- onSearchChange(e.target.value)} - placeholder={t("agentRepository.mine.searchPlaceholder")} - prefix={} - className="h-11 rounded-xl" - allowClear - /> -
-
- - -
-
- -
-
- {MINE_OWNERSHIP_FILTERS.map((filter) => ( - - ))} -
-
- - - {tagPredicates.length > 0 ? ( - - ) : null} -
- } - > - - -
-
- - {isLoading ? ( -
- -
- ) : isError ? ( -
-

- {t("agentRepository.mine.loadError")} -

- -
- ) : showFilteredEmpty ? ( - - ) : ( - <> -
- {agents.map((agent) => - isNewAgentPaddingItem(agent) ? ( -
- -
- ) : ( -
- handleEdit(agent.agent_id, agent.permission)} - onView={() => - onViewDetail( - agent.agent_id, - agent.current_version_no ?? 0 - ) - } - onApplyListing={() => handleApplyListing(agent)} - onViewReview={(mode) => handleViewReview(agent, mode)} - onDelete={() => handleDeleteAgent(agent)} - onEvaluate={() => handleEvaluate(agent)} - isApplying={ - applyingAgentId === agent.agent_id && - createListingMutation.isPending - } - isDeleting={ - deleteAgentMutation.isPending && - deleteAgentMutation.variables === agent.agent_id - } - /> -
- ) - )} -
- - {showPagination ? ( -
- - {Array.from({ length: totalPages }, (_, index) => index + 1).map( - (pageNumber) => ( - - ) - )} - -
- ) : null} - - )} - - - - - - setCreateAgentModalVisible(false)} - onCreated={handleAgentCreated} - /> - - { - setImportWizardVisible(false); - setImportWizardData(null); - }} - initialData={importWizardData} - onImportComplete={async () => { - setImportWizardVisible(false); - setImportWizardData(null); - await Promise.all([ - invalidateAgentRepositoryCaches(queryClient), - queryClient.invalidateQueries({ - queryKey: [AGENTS_LIST_QUERY_KEY], - }), - ]); - }} - /> - - ); -} diff --git a/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx index f51cae8a81..ba4c713c1a 100644 --- a/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx +++ b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx @@ -1,16 +1,27 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { App, Button, Dropdown, Input, Modal, Spin } from "antd"; -import { ChevronDown, Share2 } from "lucide-react"; +import { createElement, useEffect, useMemo, useState } from "react"; +import { + App, + Avatar, + Button, + Input, + Modal, + Spin, + Upload as AntdUpload, +} from "antd"; +import type { UploadProps } from "antd"; +import { Share2, Upload as UploadIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { AGENT_REPOSITORY_ICONS } from "@/const/agentRepository"; +import { API_ENDPOINTS } from "@/services/api"; +import { fetchWithAuth } from "@/lib/auth"; import { useAgentRepositoryListings } from "@/hooks/agentRepository/useAgentRepositoryListings"; import { getAgentRepositoryTagLabel, resolveAgentRepositoryTagForSubmit, } from "@/lib/agentRepositoryLabels"; -import { isSingleSimpleEmoji } from "@/lib/agentRepositoryIcon"; +import { getAgentIcon } from "@/lib/chat/agentIconUtils"; +import { withBasePath } from "@/lib/basePath"; import { useTagAssignments, useTagDefinitions, @@ -19,6 +30,7 @@ import { import ResourceTagAssignmentModal from "@/components/tag/ResourceTagAssignmentModal"; import { buildApplyListingFormPrefill, + getListingTagsFromAssignments, pickApplyListingPrefillSource, } from "@/lib/agentRepositoryMine"; import type { @@ -29,7 +41,6 @@ import type { TagAssignmentValue } from "@/types/tagManagement"; const MAX_TAGS = 5; const MAX_TAG_LENGTH = 20; -const MAX_ICON_LENGTH = 32; interface MineApplyListingModalProps { open: boolean; @@ -49,7 +60,6 @@ export function MineApplyListingModal({ const { t } = useTranslation("common"); const { message } = App.useApp(); - const icons = AGENT_REPOSITORY_ICONS; const { data: tagLibraries } = useTagLibraries(); const defaultResourceLibrary = useMemo( () => @@ -70,10 +80,11 @@ export function MineApplyListingModal({ ); const categoryValues = agentCategory?.values ?? []; - const [selectedIcon, setSelectedIcon] = useState(null); - const [iconInput, setIconInput] = useState(""); - const [iconError, setIconError] = useState(null); - const [presetDropdownOpen, setPresetDropdownOpen] = useState(false); + const [iconUrl, setIconUrl] = useState(null); + const [iconPreviewUrl, setIconPreviewUrl] = useState(null); + const [iconLoadError, setIconLoadError] = useState(false); + const [selectedIconFile, setSelectedIconFile] = useState(null); + const [uploadingIcon, setUploadingIcon] = useState(false); const [listingContent, setListingContent] = useState(""); const [formInitialized, setFormInitialized] = useState(false); const [tagEditorOpen, setTagEditorOpen] = useState(false); @@ -113,12 +124,9 @@ export function MineApplyListingModal({ [agentCategory?.definition_id, assignmentValues] ); - const selectedCategoryValues = useMemo( - () => - categoryValues.filter((value) => - categoryAssignmentValueIds.has(value.value_id) - ), - [categoryAssignmentValueIds, categoryValues] + const selectedListingTags = useMemo( + () => getListingTagsFromAssignments(assignmentValues, agentCategory), + [assignmentValues, agentCategory] ); const legacyCategorySelection = useMemo(() => { @@ -138,10 +146,14 @@ export function MineApplyListingModal({ value.normalized_value, value.display_value, getAgentRepositoryTagLabel(value.normalized_value, t), - ].some((candidate) => legacyTags.has(candidate.trim().toLocaleLowerCase())) + ].some((candidate) => + legacyTags.has(candidate.trim().toLocaleLowerCase()) + ) ) .map((value) => value.value_id); - return valueIds.length > 0 ? { [agentCategory.definition_id]: valueIds } : {}; + return valueIds.length > 0 + ? { [agentCategory.definition_id]: valueIds } + : {}; }, [ agent?.version_label, agentCategory, @@ -151,44 +163,14 @@ export function MineApplyListingModal({ t, ]); - const invalidIconMessage = t( - "agentRepository.mine.applyModal.validation.iconInvalid" - ); - - const applyIconInputFromValue = useCallback( - (value: string, showErrorWhenInvalid = true) => { - setIconInput(value); - - const trimmedValue = value.trim(); - if (!trimmedValue) { - setSelectedIcon(null); - setIconError(null); - return; - } - - if (isSingleSimpleEmoji(trimmedValue)) { - setSelectedIcon(trimmedValue); - setIconError(null); - return; - } - - setSelectedIcon(null); - setIconError(showErrorWhenInvalid ? invalidIconMessage : null); - }, - [invalidIconMessage] - ); - - const clearIconState = useCallback(() => { - setIconInput(""); - setSelectedIcon(null); - setIconError(null); - }, []); - useEffect(() => { if (!open) { setFormInitialized(false); setTagEditorOpen(false); setSavedAssignments(null); + setIconPreviewUrl(null); + setIconLoadError(false); + setSelectedIconFile(null); return; } @@ -205,73 +187,68 @@ export function MineApplyListingModal({ }); if (!prefill) { - clearIconState(); + setIconUrl(null); + setIconPreviewUrl(null); + setIconLoadError(false); + setSelectedIconFile(null); setListingContent(""); setFormInitialized(true); return; } - const trimmedIcon = prefill.icon?.trim(); - if (trimmedIcon && isSingleSimpleEmoji(trimmedIcon)) { - applyIconInputFromValue(trimmedIcon, false); - } else { - clearIconState(); - } + setIconUrl(prefill.icon_url); + setIconPreviewUrl(null); + setIconLoadError(false); + setSelectedIconFile(null); setListingContent(""); setFormInitialized(true); - }, [ - open, - agent, - isListingsSuccess, - listingsData, - clearIconState, - applyIconInputFromValue, - formInitialized, - ]); + }, [open, agent, isListingsSuccess, listingsData, formInitialized]); const title = agent?.name?.trim() || t("agentRepository.card.untitled"); - const handlePresetIconClick = (icon: string) => { - applyIconInputFromValue(icon, false); - setPresetDropdownOpen(false); + useEffect(() => { + return () => { + if (iconPreviewUrl) URL.revokeObjectURL(iconPreviewUrl); + }; + }, [iconPreviewUrl]); + + const handleIconUpload = (file: File) => { + if (file.size > 2 * 1024 * 1024) { + message.error(t("agentRepository.mine.applyModal.iconTooLarge")); + return false; + } + setSelectedIconFile(file); + setIconPreviewUrl(URL.createObjectURL(file)); + setIconLoadError(false); + return false; }; - const presetDropdown = ( -
-
- {icons.map((icon) => ( - - ))} -
-
- ); + const uploadProps: UploadProps = { + accept: "image/png,image/jpeg,image/gif,image/webp", + showUploadList: false, + disabled: uploadingIcon || isSubmitting, + beforeUpload: handleIconUpload, + }; + const defaultIcon = createElement(getAgentIcon({ agent_id: agentId ?? 0 }), { + size: 28, + }); + const selectedIconSource = iconPreviewUrl ?? iconUrl; + const iconSource = + selectedIconSource && !iconLoadError + ? withBasePath(selectedIconSource) + : undefined; const handleSubmit = async () => { - if (iconInput.trim() && !isSingleSimpleEmoji(iconInput)) { - setIconError(invalidIconMessage); - message.warning(invalidIconMessage); + if (uploadingIcon) { return; } - if (!selectedIcon) { - message.warning(t("agentRepository.mine.applyModal.validation.icon")); - return; - } - - if (selectedCategoryValues.length === 0 || !agentCategory) { + if (selectedListingTags.length === 0) { message.warning(t("agentRepository.mine.applyModal.validation.tags")); return; } - if (selectedCategoryValues.length > MAX_TAGS) { + if (selectedListingTags.length > MAX_TAGS) { message.warning( t("agentRepository.mine.applyModal.validation.tagsMax", { count: MAX_TAGS, @@ -279,8 +256,8 @@ export function MineApplyListingModal({ ); return; } - const tags = selectedCategoryValues.map((value) => - resolveAgentRepositoryTagForSubmit(value.normalized_value, t) + const tags = selectedListingTags.map((tag) => + resolveAgentRepositoryTagForSubmit(tag, t) ); if (tags.some((tag) => tag.length > MAX_TAG_LENGTH)) { message.warning( @@ -292,139 +269,170 @@ export function MineApplyListingModal({ } try { + let submittedIconUrl = iconUrl; + if (selectedIconFile && agentId != null && agent) { + setUploadingIcon(true); + try { + const formData = new FormData(); + formData.append("file", selectedIconFile); + const response = await fetchWithAuth( + API_ENDPOINTS.agentRepository.icon( + agentId, + agent.current_version_no ?? 0 + ), + { method: "POST", body: formData } + ); + if (!response.ok) throw new Error("Icon upload failed"); + const data = (await response.json()) as { icon_url: string }; + submittedIconUrl = data.icon_url; + setIconUrl(submittedIconUrl); + setSelectedIconFile(null); + } catch { + message.error(t("agentRepository.mine.applyModal.iconUploadFailed")); + return; + } + } await onSubmit({ - icon: selectedIcon, + icon_url: submittedIconUrl, tags, content: listingContent.trim(), }); } catch (error) { message.error(error instanceof Error ? error.message : String(error)); + } finally { + setUploadingIcon(false); } }; return ( <> - - {t("agentRepository.mine.applyModal.title")} - - } - footer={ -
- - -
- } - > -

- {t("agentRepository.mine.applyModal.agentName", { name: title })} -

- - -
-
-

- {t("agentRepository.mine.applyModal.icon")} -

- applyIconInputFromValue(event.target.value)} - maxLength={MAX_ICON_LENGTH} - status={iconError ? "error" : undefined} - className="!h-[3.75rem] !w-[6.5rem] shrink-0 !text-4xl" - styles={{ - root: { - display: "inline-flex", - alignItems: "center", - paddingBlock: 0, - }, - input: { - paddingInline: 2, - paddingBlock: 0, - textAlign: "center", - fontSize: "2.25rem", - lineHeight: 1, - }, - }} - suffix={ - presetDropdown} - > - + +
+ } + > +

+ {t("agentRepository.mine.applyModal.agentName", { name: title })} +

+ + +
+
+
+ {t("agent.icon")} +
+
+ +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.currentTarget.click(); + } + }} + > + { + setIconLoadError(true); + return false; + }} + className={`border-2 border-dashed border-gray-300 ${iconSource ? "" : "!bg-primary/10 !text-primary"}`} + /> +
+ {uploadingIcon ? ( + + ) : ( + + )} +
+
+
+
+ {t("agent.iconHint")} +
+ {(iconUrl || selectedIconFile) && ( + - - } - /> - {iconError ? ( -

{iconError}

- ) : ( + {t("agentRepository.mine.applyModal.useDefaultIcon")} + + )} +
+
+ +
+

+ {t("agentRepository.mine.applyModal.tags")} +

+
+ + {selectedListingTags.length > 0 ? ( + + {selectedListingTags + .map((tag) => getAgentRepositoryTagLabel(tag, t)) + .join(" · ")} + + ) : null} +

- {t("agentRepository.mine.applyModal.customIconHint")} + {t("agentRepository.mine.applyModal.tagsHint", { + count: MAX_TAGS, + })}

- )} -
- -
-

- {t("agentRepository.mine.applyModal.tags")} -

-
- - {selectedCategoryValues.length > 0 ? ( - - {selectedCategoryValues - .map((value) => - getAgentRepositoryTagLabel(value.normalized_value, t) - ) - .join(" · ")} - - ) : null} -
-

- {t("agentRepository.mine.applyModal.tagsHint")} -

-
+
-
-

- {t("repository.mine.applyModal.content")} -

- setListingContent(event.target.value)} - rows={4} - placeholder={t("repository.mine.applyModal.contentPlaceholder")} - /> -
- - +
+

+ {t("repository.mine.applyModal.content")} +

+ setListingContent(event.target.value)} + rows={4} + placeholder={t("repository.mine.applyModal.contentPlaceholder")} + /> +
+ +
{t("agentRepository.mine.reviewModal.version")} - + {versionLabel}
diff --git a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx index 68a3936197..d655a3635b 100644 --- a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx +++ b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx @@ -1,9 +1,8 @@ "use client"; -import { Button, Card, Dropdown } from "antd"; +import { Button, Dropdown, Tooltip } from "antd"; import type { MenuProps } from "antd"; import { - Bot, ClipboardCheck, Clock, Eye, @@ -14,7 +13,9 @@ import { Trash2, } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { getAgentUsageGuideAccess } from "@/lib/agentUsageGuide"; import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; +import { getUnavailableReasonLabels } from "@/lib/agentLabelMapper"; import { formatMineDate, getMineCardMenuActions, @@ -22,15 +23,24 @@ import { type MineCardMenuAction, } from "@/lib/agentRepositoryMine"; import type { MyEditableAgentItem } from "@/types/agentRepository"; +import ResourceCard from "@/components/resource/ResourceCard"; +import { MyAgentIcon } from "./MyAgentIcon"; interface MyAgentCardProps { agent: MyEditableAgentItem; onEdit: () => void; onView: () => void; onApplyListing: () => void; - onViewReview: (mode: "review" | "reviewUpdate") => void; + onViewReview: ( + agent: MyEditableAgentItem, + mode: "review" | "reviewUpdate" + ) => void; onDelete: () => void; onEvaluate: () => void; + onUsageGuide: () => void; + highlighted?: boolean; + guideMenuOpen?: boolean; + onGuideMenuOpenChange?: (open: boolean) => void; isApplying?: boolean; isDeleting?: boolean; } @@ -41,14 +51,6 @@ const MENU_ACTION_I18N: Record = { reviewUpdate: "agentRepository.mine.menu.reviewUpdate", }; -const STATUS_BADGE_CLASS: Record<"pending" | "shared" | "rejected", string> = { - pending: - "bg-orange-50 text-orange-700 dark:bg-orange-500/10 dark:text-orange-300", - shared: - "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300", - rejected: "bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-300", -}; - export function MyAgentCard({ agent, onEdit, @@ -57,6 +59,10 @@ export function MyAgentCard({ onViewReview, onDelete, onEvaluate, + onUsageGuide, + highlighted = false, + guideMenuOpen, + onGuideMenuOpenChange, isApplying = false, isDeleting = false, }: MyAgentCardProps) { @@ -66,17 +72,23 @@ export function MyAgentCard({ const description = agent.description?.trim() || t("agentRepository.card.noDescription"); const tags = agent.tags?.filter((tag) => tag.trim()) ?? []; - const published = (agent.current_version_no ?? 0) > 0; - const repositoryInfo = agent.repository_info ?? []; - const hasRepositoryInfo = repositoryInfo.length > 0; - const repositoryStatusBadge = - getMineCardRepositoryStatusBadge(repositoryInfo); + const unavailableReasonLabels = getUnavailableReasonLabels( + agent.unavailable_reasons ?? [], + t + ); + const { canOpen: published } = getAgentUsageGuideAccess({ + currentVersionNo: agent.current_version_no, + permission: agent.permission, + }); const footerDate = formatMineDate(agent.version_create_time); const versionLabel = agent.version_label; const canEdit = agent.permission !== "READ_ONLY"; const canView = (agent.current_version_no ?? 0) > 0; const canEvaluate = canView; const menuActions = getMineCardMenuActions(agent); + const repositoryBadge = getMineCardRepositoryStatusBadge( + agent.repository_info + ); const menuItems: MenuProps["items"] = menuActions.map((action) => { const icon = @@ -96,164 +108,206 @@ export function MyAgentCard({ onApplyListing(); return; } - onViewReview(action === "reviewUpdate" ? "reviewUpdate" : "review"); + onViewReview( + agent, + action === "reviewUpdate" ? "reviewUpdate" : "review" + ); }, }; }); - if (menuActions.length > 0) { + if (canEvaluate) { + menuItems.push({ + key: "evaluate", + icon: , + label: t("agentRepository.mine.evaluate"), + onClick: onEvaluate, + }); + } + + if (menuItems.length > 0 && canEdit) { menuItems.push({ type: "divider" }); } - menuItems.push({ - key: "delete", - danger: true, - icon: , - label: t("common.delete"), - disabled: isDeleting, - onClick: onDelete, - }); + if (published) { + menuItems.push({ + key: "usageGuide", + icon: , + label: t("agentRepository.mine.menu.usageGuide"), + className: guideMenuOpen ? "font-semibold" : undefined, + onClick: onUsageGuide, + }); + } + + if (canEdit) { + menuItems.push({ + key: "delete", + danger: true, + icon: , + label: t("common.delete"), + disabled: isDeleting, + onClick: onDelete, + }); + } return ( - -
-
-
- -
-
-
-

- {title} -

- {hasRepositoryInfo ? ( - - - {t("agentRepository.mine.onHub")} - - ) : null} -
-
+ + + + {t("agentRepository.mine.currentVersion", { + version: versionLabel, + })} + + + ) : undefined + } + icon={} + description={description} + descriptionLines={2} + tags={ + tags.length > 0 ? ( + <> + {tags.map((tag) => ( + {getAgentRepositoryTagLabel(tag, t)} + + ))} + + ) : undefined + } + statusRow={ +
+ + {published + ? t("agentRepository.mine.lifecycle.published") + : t("agentRepository.mine.lifecycle.draft")} + +
+ {repositoryBadge ? ( + - {published - ? t("agentRepository.mine.lifecycle.published") - : t("agentRepository.mine.lifecycle.draft")} + {t(repositoryBadge.labelKey)} + {repositoryBadge.versionLabel + ? ` · ${repositoryBadge.versionLabel}` + : null} - {repositoryStatusBadge ? ( - +
+ } + headerActions={ + menuItems.length > 0 || agent.is_available === false ? ( +
+
+ {menuItems.length > 0 ? ( + - {t(repositoryStatusBadge.labelKey)}{" "} - {repositoryStatusBadge.versionLabel} - +
-
-
- - {canEdit ? ( - -
- -

- {description} -

- - {tags.length > 0 ? ( -
- {tags.map((tag) => ( - - {getAgentRepositoryTagLabel(tag, t)} - - ))} -
- ) : null} - -
-
-
- {versionLabel != null ? ( - - - {t("agentRepository.mine.currentVersion", { - version: versionLabel, - })} - - ) : null} -
-
- {footerDate ? ( - - - {footerDate} - + {agent.is_available === false ? ( +
+ 0 + ? unavailableReasonLabels.join(", ") + : t("agentSelector.agentUnavailable") + } + > + + {t("mcpConfig.status.unavailable")} + + +
) : null}
-
- -
- {canEdit ? ( - - ) : ( - - )} + ) : undefined + } + fixedHeaderLayout + footerLayout="inline" + meta={ + footerDate ? ( + + + {footerDate} + + ) : undefined + } + footer={ + canEdit ? ( -
-
- + ) : ( + + ) + } + /> ); } diff --git a/frontend/app/[locale]/agent-space/components/MyAgentIcon.tsx b/frontend/app/[locale]/agent-space/components/MyAgentIcon.tsx new file mode 100644 index 0000000000..0d575a958b --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/MyAgentIcon.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Avatar } from "antd"; +import { createElement } from "react"; +import { + getAgentIcon, + getAgentUploadedIconId, + getAgentUploadedIconRevision, +} from "@/lib/chat/agentIconUtils"; +import { API_ENDPOINTS } from "@/services/api"; +import type { MyEditableAgentItem } from "@/types/agentRepository"; + +interface MyAgentIconProps { + agent: Pick; + size: number; + iconSize: number; +} + +export function MyAgentIcon({ agent, size, iconSize }: MyAgentIconProps) { + const fallbackIcon = createElement( + getAgentIcon({ agent_id: agent.agent_id }), + { + size: iconSize, + "aria-hidden": true, + } + ); + const uploadedIconId = getAgentUploadedIconId(agent); + + return ( + + ); +} diff --git a/frontend/app/[locale]/agent-space/components/RepositoryAgentIcon.tsx b/frontend/app/[locale]/agent-space/components/RepositoryAgentIcon.tsx new file mode 100644 index 0000000000..f19bddd4d4 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/RepositoryAgentIcon.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Avatar } from "antd"; +import { createElement } from "react"; +import { getAgentIcon } from "@/lib/chat/agentIconUtils"; +import { withBasePath } from "@/lib/basePath"; + +interface RepositoryAgentIconProps { + agentId?: number | null; + iconUrl?: string | null; + size: number; + iconSize: number; +} + +export function RepositoryAgentIcon({ + agentId, + iconUrl, + size, + iconSize, +}: RepositoryAgentIconProps) { + const DefaultIcon = getAgentIcon({ agent_id: agentId ?? 0 }); + return ( + + ); +} diff --git a/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx b/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx index 016a1039a1..b64b161c65 100644 --- a/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx +++ b/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx @@ -1,13 +1,13 @@ "use client"; import { Button } from "antd"; -import { Bot, Check, Eye, X } from "lucide-react"; +import { Check, Eye, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import type { AgentRepositoryListingItem } from "@/types/agentRepository"; +import { RepositoryAgentIcon } from "./RepositoryAgentIcon"; -const GRID_COLS = - "grid-cols-[minmax(0,2fr)_120px_160px_minmax(0,1.5fr)_280px]"; +const GRID_COLS = "grid-cols-[minmax(0,2fr)_120px_160px_minmax(0,1.5fr)_280px]"; interface ReviewAgentListProps { listings: AgentRepositoryListingItem[]; @@ -18,10 +18,7 @@ interface ReviewAgentListProps { onReject: (listing: AgentRepositoryListingItem) => void; } -function getListingTitle( - listing: AgentRepositoryListingItem, - t: TFunction -) { +function getListingTitle(listing: AgentRepositoryListingItem, t: TFunction) { return ( listing.display_name?.trim() || listing.name?.trim() || @@ -76,8 +73,7 @@ export function ReviewAgentList({ const isUpdating = updatingRepositoryId === listing.agent_repository_id; const versionLabel = - listing.version_label?.trim() || - t("repository.review.noVersion"); + listing.version_label?.trim() || t("repository.review.noVersion"); const submitter = getSubmitterDisplay( listing.submitted_by, currentUserEmail, @@ -92,18 +88,19 @@ export function ReviewAgentList({ >
- {listing.icon?.trim() ? ( - {listing.icon.trim()} - ) : ( - - )} +

{title}

-
+
{versionLabel}
diff --git a/frontend/app/[locale]/agent-space/my-agent.tsx b/frontend/app/[locale]/agent-space/my-agent.tsx new file mode 100644 index 0000000000..949acd5ccc --- /dev/null +++ b/frontend/app/[locale]/agent-space/my-agent.tsx @@ -0,0 +1,895 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { App, Button, Empty, Grid, Input, Spin } from "antd"; +import { Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import CreateAgentModal, { + type CreatedAgentResult, +} from "@/components/agent/CreateAgentModal"; +import { useConfirmModal } from "@/hooks/useConfirmModal"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { useToolList } from "@/hooks/agent/useToolList"; +import { useSkillList } from "@/hooks/agent/useSkillList"; +import type { Agent } from "@/types/agentConfig"; +import type { AgentListFilters } from "@/services/agentConfigService"; +import { deleteAgent } from "@/services/agentConfigService"; +import { + AGENTS_LIST_QUERY_KEY, + invalidateAgentRepositoryCaches, + useCreateAgentRepositoryListing, + useMyEditableAgents, + useAgentRepositoryListings, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; +import { getTagSearchPredicates } from "@/lib/systemTagLabels"; +import { parseReviewDeepLinkParams } from "@/lib/notificationNavigation"; +import { parseAgentUsageGuideParams } from "@/lib/agentUsageGuide"; +import log from "@/lib/logger"; +import { + getAgentUsageGuideOpenAction, + parseAgentUsageGuideTargetParams, + resolveAgentUsageGuideTarget, +} from "@/lib/agentUsageGuide"; +import { + isCancelableRepositoryStatus, + isTakeDownableRepositoryStatus, + findRepositoryInfoById, + pickReviewDisplayRepositoryInfo, + resolveReviewModalMode, + toMineRepositoryInfo, +} from "@/lib/agentRepositoryMine"; +import { + isNewAgentPaddingItem, + type AgentRepositoryListingCreatePayload, + type MineOwnershipFilter, + type MyAgentRepositoryInfoItem, + type MyEditableAgentItem, +} from "@/types/agentRepository"; +import { MineApplyListingModal } from "./components/MineApplyListingModal"; +import { MineReviewStatusModal } from "./components/MineReviewStatusModal"; +import { AgentUsageGuideModal } from "./components/AgentUsageGuideModal"; +import { CreateNewAgentCard } from "./components/CreateNewAgentCard"; +import { MyAgentCard } from "./components/MyAgentCard"; +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import TagFilterPopover from "@/components/tag/TagFilterPopover"; +import type { TagResourcePredicate } from "@/types/tagManagement"; +import { useAgentVersionDetail } from "@/hooks/agent/useAgentVersionDetail"; +import { mapMyAgentDetail } from "@/lib/myAgentDetail"; +import { AgentDetail } from "@/components/agent/agent-detail"; +import { MyAgentIcon } from "./components/MyAgentIcon"; + +const MINE_OWNERSHIP_FILTERS: MineOwnershipFilter[] = [ + "all", + "created", + "others", +]; +const CARD_GAP = 20; +const MIN_CARD_HEIGHT = 240; +const PAGINATION_HEIGHT = 60; + +export function MyAgent({ + active, + onTotalChange, +}: { + active: boolean; + onTotalChange?: (total: number) => void; +}) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { confirm } = useConfirmModal(); + const { user } = useAuthorizationContext(); + const router = useRouter(); + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const params = useParams<{ locale: string }>(); + const locale = params.locale || "en"; + const screens = Grid.useBreakpoint(); + const gridRegionRef = useRef(null); + const [availableGridHeight, setAvailableGridHeight] = useState( + null + ); + const columns = screens.xxl + ? 4 + : screens.xl + ? 3 + : screens.lg || screens.md || screens.sm + ? 2 + : screens.xs + ? 1 + : 4; + const pageBottomPadding = screens.sm ? 40 : 32; + const rows = getRowCount( + Math.max(0, (availableGridHeight ?? 0) - PAGINATION_HEIGHT) + ); + const gridSlots = columns * rows; + const measureGridHeight = useCallback(() => { + if (!active || !gridRegionRef.current) return; + + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const { top } = gridRegionRef.current.getBoundingClientRect(); + setAvailableGridHeight( + Math.max(0, Math.floor(viewportHeight - top - pageBottomPadding - 8)) + ); + }, [active, pageBottomPadding]); + + useEffect(() => { + if (!active) return; + + const frame = window.requestAnimationFrame(measureGridHeight); + const observer = new ResizeObserver(measureGridHeight); + const visualViewport = window.visualViewport; + if (gridRegionRef.current) observer.observe(gridRegionRef.current); + window.addEventListener("resize", measureGridHeight); + visualViewport?.addEventListener("resize", measureGridHeight); + + return () => { + window.cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", measureGridHeight); + visualViewport?.removeEventListener("resize", measureGridHeight); + }; + }, [active, measureGridHeight]); + const [ownership, setOwnership] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [tagPredicates, setTagPredicates] = useState( + [] + ); + const [page, setPage] = useState(1); + const { data: tagLibraries } = useTagLibraries(); + const defaultTagLibrary = + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: tagDefinitions } = useTagDefinitions( + defaultTagLibrary?.bucket_id ?? null + ); + const searchTagPredicates = useMemo( + () => getTagSearchPredicates(tagDefinitions, searchQuery, t), + [tagDefinitions, searchQuery, t] + ); + const createCardInGrid = gridSlots > 1; + const pageSize = Math.max(1, gridSlots - (createCardInGrid ? 1 : 0)); + const listParams = useMemo( + (): AgentListFilters => ({ + tenantId: user?.tenantId ?? null, + enabled: active, + includeRepositoryInfo: true, + page, + pageSize, + search: searchQuery.trim() || undefined, + tagPredicates, + searchTagPredicates, + createdBy: ownership === "created" ? user?.id : undefined, + createdByNot: ownership === "others" ? user?.id : undefined, + }), + [ + active, + user?.tenantId, + user?.id, + ownership, + page, + pageSize, + searchQuery, + tagPredicates, + searchTagPredicates, + ] + ); + const { + agents: listedAgents, + creatorCounts, + pagination, + isLoading, + isError, + isFetching, + refetch, + } = useAgentList(listParams); + const agents: MyEditableAgentItem[] = useMemo( + () => listedAgents.map(toMyAgentItem), + [listedAgents] + ); + const counts = creatorCounts ?? { all: 0, created: 0, others: 0 }; + const total = pagination?.total ?? 0; + useEffect(() => { + if (active && creatorCounts && tagPredicates.length === 0) { + onTotalChange?.(creatorCounts.all); + } + }, [active, creatorCounts, onTotalChange, tagPredicates.length]); + const gridHeight = + availableGridHeight === null + ? undefined + : Math.max(0, availableGridHeight - (total > 0 ? PAGINATION_HEIGHT : 0)); + const reviewDeepLink = useMemo( + () => parseReviewDeepLinkParams(searchParams), + [searchParams] + ); + const usageGuideDeepLink = useMemo( + () => parseAgentUsageGuideParams(searchParams), + [searchParams] + ); + const usageGuideTarget = useMemo( + () => parseAgentUsageGuideTargetParams(searchParams), + [searchParams] + ); + const { data: deepLinkMineData, isLoading: deepLinkFallbackLoading } = + useMyEditableAgents( + { + ownership: "all", + agent_id: reviewDeepLink?.agentId ?? usageGuideTarget?.agentId, + page: 1, + page_size: 1, + new_agent_padding: false, + }, + active && (reviewDeepLink != null || usageGuideTarget != null) + ); + const deepLinkFallbackAgent = useMemo(() => { + const item = deepLinkMineData?.items?.[0]; + return item && !isNewAgentPaddingItem(item) ? item : null; + }, [deepLinkMineData]); + const onReviewDeepLinkConsumed = useCallback(() => { + router.replace(`/${locale}/agent-space?tab=mine`); + }, [locale, router]); + const onUsageGuideDeepLinkConsumed = useCallback(() => { + if (!usageGuideTarget) return; + router.replace( + `/${locale}/agent-space?tab=mine&agent_id=${usageGuideTarget.agentId}` + ); + }, [locale, router, usageGuideTarget]); + const onOwnershipChange = (value: MineOwnershipFilter) => { + setOwnership(value); + setPage(1); + }; + const onSearchChange = (value: string) => { + setSearchQuery(value); + setPage(1); + }; + const onTagPredicatesChange = (value: TagResourcePredicate[]) => { + setTagPredicates(value); + setPage(1); + }; + const [createAgentModalVisible, setCreateAgentModalVisible] = useState(false); + const [reviewModalOpen, setReviewModalOpen] = useState(false); + const [reviewModalAgent, setReviewModalAgent] = + useState(null); + const [reviewModalInfo, setReviewModalInfo] = + useState(null); + const [reviewModalMode, setReviewModalMode] = useState< + "review" | "reviewUpdate" + >("review"); + const [applyingAgentId, setApplyingAgentId] = useState(null); + const [detailTarget, setDetailTarget] = useState<{ + agentId: number; + versionNo: number; + agent: MyEditableAgentItem; + } | null>(null); + const { + data: versionDetail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + refetch: refetchDetail, + } = useAgentVersionDetail( + detailTarget?.agentId ?? null, + detailTarget?.versionNo ?? null, + active && detailTarget != null + ); + const { tools: availableTools } = useToolList({ + enabled: active && detailTarget != null, + }); + const { skills: availableSkills } = useSkillList({ + enabled: active && detailTarget != null, + }); + const { + data: detailListings, + isLoading: isDetailListingsLoading, + isError: isDetailListingsError, + isFetching: isDetailListingsFetching, + refetch: refetchDetailListings, + } = useAgentRepositoryListings( + detailTarget + ? { agent_id: detailTarget.agentId, page: 1, page_size: 100 } + : undefined, + active && detailTarget != null + ); + const detail = useMemo(() => { + if (!detailTarget) return null; + if (!versionDetail) return undefined; + const repositoryInfo = pickReviewDisplayRepositoryInfo( + toMineRepositoryInfo(detailListings?.items ?? []) + ); + return { + ...mapMyAgentDetail(versionDetail, detailTarget.agent, { + tools: availableTools, + skills: availableSkills, + }), + status: repositoryInfo?.status, + }; + }, [ + availableSkills, + availableTools, + detailTarget, + detailListings, + versionDetail, + ]); + const [applyModalOpen, setApplyModalOpen] = useState(false); + const [applyModalAgent, setApplyModalAgent] = + useState(null); + const [usageGuideAgent, setUsageGuideAgent] = + useState(null); + const [guidedMenuAgentId, setGuidedMenuAgentId] = useState( + null + ); + const consumedDeepLinkRef = useRef(null); + const consumedUsageGuideRef = useRef(null); + + const createListingMutation = useCreateAgentRepositoryListing(); + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + const deleteAgentMutation = useMutation({ + mutationFn: (agentId: number) => deleteAgent(agentId), + }); + + const normalizedQuery = searchQuery.trim().toLowerCase(); + const usageGuideTargetState = useMemo( + () => + usageGuideTarget + ? resolveAgentUsageGuideTarget({ + agentId: usageGuideTarget.agentId, + agents, + fallbackAgent: deepLinkFallbackAgent, + isListLoading: isLoading, + isFallbackLoading: deepLinkFallbackLoading, + isActive: active, + getAgentId: (agent) => + isNewAgentPaddingItem(agent) ? null : agent.agent_id, + }) + : null, + [ + agents, + deepLinkFallbackAgent, + deepLinkFallbackLoading, + isLoading, + usageGuideTarget, + ] + ); + const displayedAgents = useMemo(() => { + if (usageGuideTargetState?.state !== "found") { + return agents; + } + const targetAgent = usageGuideTargetState.agent; + if ( + isNewAgentPaddingItem(targetAgent) || + agents.some( + (agent) => + !isNewAgentPaddingItem(agent) && + agent.agent_id === targetAgent.agent_id + ) + ) { + return agents; + } + return [targetAgent, ...agents]; + }, [agents, usageGuideTargetState]); + const highlightedAgentId = + usageGuideTargetState?.state === "found" && + !isNewAgentPaddingItem(usageGuideTargetState.agent) + ? usageGuideTargetState.agent.agent_id + : null; + + const handleCreateAgent = () => { + setCreateAgentModalVisible(true); + }; + + const handleAgentCreated = async ({ agentId }: CreatedAgentResult) => { + setCreateAgentModalVisible(false); + await Promise.all([ + invalidateAgentRepositoryCaches(queryClient), + queryClient.invalidateQueries({ queryKey: [AGENTS_LIST_QUERY_KEY] }), + ]); + router.push(`/${locale}/agents/${agentId}`); + }; + + const handleEdit = ( + agentId: number, + permission?: MyEditableAgentItem["permission"] + ) => { + if (permission === "READ_ONLY") { + return; + } + router.push(`/${locale}/agents/${agentId}?from=agent-space&tab=mine`); + }; + + const handleDeleteAgent = (agent: MyEditableAgentItem) => { + const name = agent.name?.trim() || t("agentRepository.card.untitled"); + confirm({ + title: t("businessLogic.config.modal.deleteTitle"), + content: t("businessLogic.config.modal.deleteContent", { name }), + onOk: async () => { + try { + const result = await deleteAgentMutation.mutateAsync(agent.agent_id); + if (!result.success) { + throw new Error(result.message || "delete failed"); + } + message.success( + t("businessLogic.config.error.agentDeleteSuccess", { name }) + ); + await Promise.all([ + invalidateAgentRepositoryCaches(queryClient), + queryClient.invalidateQueries({ + queryKey: [AGENTS_LIST_QUERY_KEY], + }), + ]); + } catch (error) { + log.error("Failed to delete agent:", error); + message.error(t("businessLogic.config.error.agentDeleteFailed")); + throw error; + } + }, + }); + }; + + const handleEvaluate = (agent: MyEditableAgentItem) => { + const versionNo = agent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + router.push( + `/${locale}/evaluation?agent_ids=${encodeURIComponent(JSON.stringify([agent.agent_id]))}` + ); + }; + + const closeReviewModal = () => { + setReviewModalOpen(false); + setReviewModalAgent(null); + setReviewModalInfo(null); + }; + + const handleApplyListing = (agent: MyEditableAgentItem) => { + const versionNo = agent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + setApplyModalAgent(agent); + setApplyModalOpen(true); + }; + + const closeApplyModal = () => { + setApplyModalOpen(false); + setApplyModalAgent(null); + }; + + const handleSubmitApplyListing = async ( + payload: AgentRepositoryListingCreatePayload + ) => { + if (!applyModalAgent) { + return; + } + + const versionNo = applyModalAgent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + + setApplyingAgentId(applyModalAgent.agent_id); + try { + await createListingMutation.mutateAsync({ + agentId: applyModalAgent.agent_id, + versionNo, + payload, + }); + message.success(t("repository.mine.applySuccess")); + closeApplyModal(); + } catch { + message.error(t("repository.mine.applyError")); + } finally { + setApplyingAgentId(null); + } + }; + + const handleViewReview = ( + agent: MyEditableAgentItem, + mode: "review" | "reviewUpdate" + ) => { + const repositoryInfo = pickReviewDisplayRepositoryInfo( + agent.repository_info ?? [] + ); + if (!repositoryInfo) { + return; + } + openReviewModal(agent, repositoryInfo, mode); + }; + + const openReviewModal = ( + agent: MyEditableAgentItem, + repositoryInfo: MyAgentRepositoryInfoItem, + mode: "review" | "reviewUpdate" + ) => { + setReviewModalAgent(agent); + setReviewModalInfo(repositoryInfo); + setReviewModalMode(mode); + setReviewModalOpen(true); + }; + + useEffect(() => { + if (!active) return; + if (!reviewDeepLink) { + consumedDeepLinkRef.current = null; + return; + } + + if (consumedDeepLinkRef.current === reviewDeepLink.agentRepositoryId) { + return; + } + + const listStillLoading = isLoading; + const fallbackStillLoading = deepLinkFallbackLoading; + if (listStillLoading && fallbackStillLoading) { + return; + } + + const agentFromList = agents.find( + (item) => item.agent_id === reviewDeepLink.agentId + ); + const agent = deepLinkFallbackAgent ?? agentFromList; + + if (!agent) { + if (listStillLoading || fallbackStillLoading) { + return; + } + message.error(t("notifications.deepLink.agentNotFound")); + consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; + onReviewDeepLinkConsumed?.(); + return; + } + + const repositoryInfo = findRepositoryInfoById( + agent.repository_info ?? [], + reviewDeepLink.agentRepositoryId + ); + + if (!repositoryInfo) { + message.error(t("notifications.deepLink.agentNotFound")); + consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; + onReviewDeepLinkConsumed?.(); + return; + } + + openReviewModal( + agent, + repositoryInfo, + resolveReviewModalMode(agent, repositoryInfo) + ); + consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; + onReviewDeepLinkConsumed?.(); + }, [ + active, + agents, + deepLinkFallbackAgent, + deepLinkFallbackLoading, + isLoading, + message, + onReviewDeepLinkConsumed, + reviewDeepLink, + t, + ]); + + useEffect(() => { + if (!usageGuideDeepLink) { + consumedUsageGuideRef.current = null; + return; + } + + if (!usageGuideTargetState) { + return; + } + const openAction = getAgentUsageGuideOpenAction({ + agentId: usageGuideDeepLink.agentId, + consumedAgentId: consumedUsageGuideRef.current, + target: usageGuideTargetState, + }); + if (openAction.action === "ignore" || openAction.action === "wait") { + return; + } + if (openAction.action === "missing") { + message.error(t("notifications.usageGuide.agentNotFound")); + consumedUsageGuideRef.current = usageGuideDeepLink.agentId; + onUsageGuideDeepLinkConsumed?.(); + return; + } + + if (isNewAgentPaddingItem(openAction.agent)) { + return; + } + setGuidedMenuAgentId(openAction.agent.agent_id); + consumedUsageGuideRef.current = usageGuideDeepLink.agentId; + onUsageGuideDeepLinkConsumed?.(); + }, [ + onUsageGuideDeepLinkConsumed, + t, + usageGuideDeepLink, + usageGuideTargetState, + ]); + + const closeUsageGuide = () => { + setUsageGuideAgent(null); + }; + + const handleSetNotShared = async () => { + if (!reviewModalInfo) { + return; + } + + const canUpdate = + isCancelableRepositoryStatus(reviewModalInfo.status) || + isTakeDownableRepositoryStatus(reviewModalInfo.status); + if (!canUpdate) { + return; + } + + const wasShared = reviewModalInfo.status === "shared"; + + try { + await updateStatusMutation.mutateAsync({ + agentRepositoryId: reviewModalInfo.agent_repository_id, + status: "not_shared", + }); + message.success( + wasShared + ? t("repository.mine.takeDownSuccess") + : t("repository.mine.cancelApplySuccess") + ); + closeReviewModal(); + } catch { + message.error( + wasShared + ? t("repository.mine.takeDownError") + : t("repository.mine.cancelApplyError") + ); + throw new Error("Update repository status failed"); + } + }; + + const ownershipLabelKey: Record = { + all: "repository.mine.filter.all", + created: "repository.mine.filter.created", + others: "repository.mine.filter.others", + }; + + const hasActiveFilter = + ownership !== "all" || + normalizedQuery.length > 0 || + tagPredicates.length > 0; + const showFilteredEmpty = + !isLoading && !isError && agents.length === 0 && hasActiveFilter; + return ( +
+
+
+ onSearchChange(e.target.value)} + placeholder={t("agentRepository.mine.searchPlaceholder")} + prefix={} + className="rounded-xl" + allowClear + /> +
+ +
+ +
+ {MINE_OWNERSHIP_FILTERS.map((filter) => ( + + ))} +
+ +
+ {!createCardInGrid ? ( +
+ +
+ ) : null} + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ {t("agentRepository.mine.loadError")} +

+ +
+ ) : ( + <> + + ) : undefined + } + showCreateCard={createCardInGrid} + createCard={ + createCardInGrid ? ( + + ) : undefined + } + renderItem={(agent) => ( + handleEdit(agent.agent_id, agent.permission)} + onView={() => + setDetailTarget({ + agentId: agent.agent_id, + versionNo: agent.current_version_no ?? 0, + agent, + }) + } + onApplyListing={() => handleApplyListing(agent)} + onViewReview={handleViewReview} + onDelete={() => handleDeleteAgent(agent)} + onEvaluate={() => handleEvaluate(agent)} + onUsageGuide={() => { + setGuidedMenuAgentId(null); + setUsageGuideAgent(agent); + }} + highlighted={highlightedAgentId === agent.agent_id} + guideMenuOpen={ + guidedMenuAgentId === agent.agent_id ? true : undefined + } + onGuideMenuOpenChange={(open) => { + if (!open && guidedMenuAgentId === agent.agent_id) { + setGuidedMenuAgentId(null); + } + }} + isApplying={ + applyingAgentId === agent.agent_id && + createListingMutation.isPending + } + isDeleting={ + deleteAgentMutation.isPending && + deleteAgentMutation.variables === agent.agent_id + } + /> + )} + /> + {showFilteredEmpty && createCardInGrid ? ( + + ) : null} + + )} +
+ + + + + + + + setCreateAgentModalVisible(false)} + onCreated={handleAgentCreated} + /> + setDetailTarget(null)} + onEdit={ + detailTarget?.agent.permission === "READ_ONLY" + ? undefined + : detailTarget + ? () => + handleEdit( + detailTarget.agentId, + detailTarget.agent.permission + ) + : undefined + } + detail={detail} + agentIcon={ + detailTarget ? ( + + ) : undefined + } + published={(detailTarget?.versionNo ?? 0) > 0} + status={detail?.status} + isLoading={isDetailLoading || isDetailListingsLoading} + isError={isDetailError || isDetailListingsError} + isFetching={isDetailFetching || isDetailListingsFetching} + onRetry={() => { + void refetchDetail(); + void refetchDetailListings(); + }} + /> +
+ ); +} + +function toMyAgentItem(agent: Agent): MyEditableAgentItem { + const currentVersionNo = agent.current_version_no ?? 0; + return { + agent_id: Number(agent.id), + icon_url: agent.icon_url, + is_available: agent.is_available, + unavailable_reasons: agent.unavailable_reasons, + name: agent.display_name || agent.name, + description: agent.description, + current_version_no: currentVersionNo, + version_label: + agent.version_label ?? + (currentVersionNo > 0 ? `V${currentVersionNo}` : null), + version_create_time: agent.version_create_time ?? null, + permission: agent.permission, + tags: agent.tags, + repository_info: agent.repository_info ?? [], + }; +} + +function getRowCount(availableHeight: number) { + if (availableHeight <= 0) return 3; + return Math.min( + 3, + Math.max( + 1, + Math.floor((availableHeight + CARD_GAP) / (MIN_CARD_HEIGHT + CARD_GAP)) + ) + ); +} diff --git a/frontend/app/[locale]/agent-space/page.tsx b/frontend/app/[locale]/agent-space/page.tsx index 1ab2532e80..418d492203 100644 --- a/frontend/app/[locale]/agent-space/page.tsx +++ b/frontend/app/[locale]/agent-space/page.tsx @@ -1,52 +1,20 @@ -"use client"; +"use client"; -import { useEffect, useMemo, useState, useCallback } from "react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { - App, - Button, - ConfigProvider, - Empty, - Input, - Modal, - Spin, -} from "antd"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { ConfigProvider } from "antd"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; -import { Bot, ChevronLeft, ChevronRight, Inbox, Search, ShieldCheck, User } from "lucide-react"; +import { Bot, Inbox, ShieldCheck, User } from "lucide-react"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { USER_ROLES } from "@/const/auth"; import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; -import { getTagSearchPredicates } from "@/lib/systemTagLabels"; -import type { TagDefinition, TagResourcePredicate } from "@/types/tagManagement"; -import { - useAgentRepositoryListingDetail, - useAgentRepositoryListings, - useMyEditableAgents, - useUpdateAgentRepositoryStatus, -} from "@/hooks/agentRepository/useAgentRepositoryListings"; -import { useAgentVersionDetail } from "@/hooks/agent/useAgentVersionDetail"; -import { - mapAgentVersionDetail, - mapRepositoryListingDetail, - type AgentDetailModalData, -} from "@/lib/agentRepositoryDetail"; -import type { AgentRepositoryListingItem, MineOwnershipFilter } from "@/types/agentRepository"; -import { isNewAgentPaddingItem } from "@/types/agentRepository"; -import { parseReviewDeepLinkParams } from "@/lib/notificationNavigation"; -import { cn } from "@/lib/utils"; -import { AgentRepositoryCard } from "./components/AgentRepositoryCard"; -import TagFilterPopover from "@/components/tag/TagFilterPopover"; -import { AgentRepositoryCopyDialog } from "./components/AgentRepositoryCopyDialog"; -import { AgentRepositoryDetailModal } from "./components/AgentRepositoryDetailModal"; -import { MineAgentsView } from "./components/MineAgentsView"; -import { ReviewAgentList } from "./components/ReviewAgentList"; -import { - AgentRepositoryReviewConfirmModal, - type AgentRepositoryReviewAction, -} from "./components/AgentRepositoryReviewConfirmModal"; +import { useAgentRepositoryListings } from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { AgentSpace } from "./agent-space"; +import { MyAgent } from "./my-agent"; +import { ReviewCenter } from "./review-center"; enum AgentRepositoryTab { REPOSITORY = "repository", @@ -54,14 +22,6 @@ enum AgentRepositoryTab { REVIEW = "review", } -const MINE_PAGE_SIZE = 6; -const REPOSITORY_PAGE_SIZE = 6; -const REVIEW_PAGE_SIZE = 10; - -type AgentDetailSource = - | { kind: "repository"; agentRepositoryId: number } - | { kind: "mine"; agentId: number; versionNo: number }; - const agentRepositoryTheme = { token: { colorPrimary: "#2563eb", colorInfo: "#3b82f6" }, }; @@ -70,12 +30,8 @@ export default function AgentRepositoryPage() { const { t } = useTranslation("common"); const { pageVariants, pageTransition } = useSetupFlow(); const searchParams = useSearchParams(); - const router = useRouter(); - const params = useParams<{ locale: string }>(); - const locale = params.locale || "en"; const { user } = useAuthorizationContext(); const isAdmin = user?.role === USER_ROLES.ADMIN; - const [tab, setTab] = useState(() => { const backTab = searchParams.get("back_tab"); if (backTab === "mine") return AgentRepositoryTab.MINE; @@ -83,26 +39,10 @@ export default function AgentRepositoryPage() { if (backTab === "review") return AgentRepositoryTab.REVIEW; return AgentRepositoryTab.REPOSITORY; }); - const [searchQuery, setSearchQuery] = useState(""); - const [repositoryTagPredicates, setRepositoryTagPredicates] = useState< - TagResourcePredicate[] - >([]); - const [repositoryPage, setRepositoryPage] = useState(1); - const [mineOwnership, setMineOwnership] = useState("all"); - const [minePage, setMinePage] = useState(1); - const [mineSearch, setMineSearch] = useState(""); - const [mineTagPredicates, setMineTagPredicates] = useState< - TagResourcePredicate[] - >([]); - const [reviewPage, setReviewPage] = useState(1); - const [detailSource, setDetailSource] = useState( - null - ); - const [copyOpen, setCopyOpen] = useState(false); - const [copyListing, setCopyListing] = useState(null); useEffect(() => { const tabParam = searchParams.get("tab"); + /* eslint-disable react-hooks/set-state-in-effect -- URL changes must update the selected tab. */ if (tabParam === AgentRepositoryTab.MINE) { setTab(AgentRepositoryTab.MINE); return; @@ -114,270 +54,33 @@ export default function AgentRepositoryPage() { if (tabParam === AgentRepositoryTab.REVIEW && isAdmin) { setTab(AgentRepositoryTab.REVIEW); } + /* eslint-enable react-hooks/set-state-in-effect */ }, [searchParams, isAdmin]); const isRepositoryTab = tab === AgentRepositoryTab.REPOSITORY; - const isReviewTab = tab === AgentRepositoryTab.REVIEW; + const isReviewTab = tab === AgentRepositoryTab.REVIEW && isAdmin; const isMineTab = tab === AgentRepositoryTab.MINE; - const { data: tagLibraries } = useTagLibraries(); - const defaultTagLibrary = - tagLibraries?.find((library) => library.bucket_key === "default_resource") ?? - null; - const { data: mineTagDefinitions } = useTagDefinitions( - defaultTagLibrary?.bucket_id ?? null - ); - const repositorySearchTagPredicates = useMemo( - () => getTagSearchPredicates(mineTagDefinitions, searchQuery, t), - [mineTagDefinitions, searchQuery, t] - ); - const mineSearchTagPredicates = useMemo( - () => getTagSearchPredicates(mineTagDefinitions, mineSearch, t), - [mineSearch, mineTagDefinitions, t] - ); - - const reviewDeepLink = useMemo( - () => parseReviewDeepLinkParams(searchParams), - [searchParams] - ); - - const handleReviewDeepLinkConsumed = useCallback(() => { - router.replace(`/${locale}/agent-space?tab=mine`); - }, [locale, router]); - - const listingParams = useMemo( - () => ({ - status: "shared" as const, - page: repositoryPage, - page_size: REPOSITORY_PAGE_SIZE, - ...(searchQuery.trim() ? { search: searchQuery.trim() } : {}), - ...(repositorySearchTagPredicates.length > 0 - ? { search_tag_predicates: repositorySearchTagPredicates } - : {}), - ...(repositoryTagPredicates.length > 0 - ? { tag_predicates: repositoryTagPredicates } - : {}), - }), - [ - repositoryPage, - repositorySearchTagPredicates, - repositoryTagPredicates, - searchQuery, - ] - ); - - const { data, isLoading, isError, refetch, isFetching } = - useAgentRepositoryListings(listingParams, isRepositoryTab); - + const [activeMineCount, setActiveMineCount] = useState(null); const { data: repositoryCountData } = useAgentRepositoryListings( { status: "shared", page: 1, page_size: 1 }, - true + isRepositoryTab ); - - const mineListParams = useMemo( - () => ({ - ownership: mineOwnership, - page: minePage, - page_size: MINE_PAGE_SIZE, - ...(mineSearch.trim() ? { search: mineSearch.trim() } : {}), - ...(mineTagPredicates.length > 0 - ? { tag_predicates: mineTagPredicates } - : {}), - ...(mineSearchTagPredicates.length > 0 - ? { search_tag_predicates: mineSearchTagPredicates } - : {}), - ...(mineOwnership === "all" && !mineSearch.trim() - && mineTagPredicates.length === 0 - ? { new_agent_padding: true } - : {}), - }), - [mineOwnership, minePage, mineSearch, mineSearchTagPredicates, mineTagPredicates] - ); - - const { - data: mineData, - isLoading: isMineLoading, - isError: isMineError, - isFetching: isMineFetching, - refetch: refetchMine, - } = useMyEditableAgents(mineListParams, isMineTab); - - const { - data: deepLinkMineData, - isLoading: isDeepLinkMineLoading, - } = useMyEditableAgents( - { - ownership: "all", - agent_id: reviewDeepLink?.agentId, - page: 1, - page_size: 1, - new_agent_padding: false, - }, - isMineTab && reviewDeepLink != null - ); - - const { data: mineCountData } = useMyEditableAgents( - { page: 1, page_size: 1, ownership: "all" }, - true - ); - - const reviewListParams = useMemo( - () => ({ - status: "pending_review" as const, - page: reviewPage, - page_size: REVIEW_PAGE_SIZE, - }), - [reviewPage] - ); - - const { - data: reviewData, - isLoading: isReviewLoading, - isError: isReviewError, - isFetching: isReviewFetching, - refetch: refetchReview, - } = useAgentRepositoryListings(reviewListParams, isAdmin && isReviewTab); - + const { pagination: mineCountPagination } = useAgentList({ + tenantId: user?.tenantId ?? null, + page: 1, + pageSize: 1, + enabled: !isMineTab, + }); const { data: reviewCountData } = useAgentRepositoryListings( { status: "pending_review", page: 1, page_size: 1 }, isAdmin ); - - const updateStatusMutation = useUpdateAgentRepositoryStatus(); - - const detailOpen = detailSource !== null; - const selectedRepositoryId = - detailSource?.kind === "repository" ? detailSource.agentRepositoryId : null; - const mineDetailAgentId = - detailSource?.kind === "mine" ? detailSource.agentId : null; - const mineDetailVersionNo = - detailSource?.kind === "mine" ? detailSource.versionNo : null; - - const { - data: repositoryDetail, - isLoading: isRepositoryDetailLoading, - isError: isRepositoryDetailError, - isFetching: isRepositoryDetailFetching, - refetch: refetchRepositoryDetail, - } = useAgentRepositoryListingDetail( - selectedRepositoryId, - detailOpen && detailSource?.kind === "repository" - ); - - const { - data: mineVersionDetail, - isLoading: isMineVersionDetailLoading, - isError: isMineVersionDetailError, - isFetching: isMineVersionDetailFetching, - refetch: refetchMineVersionDetail, - } = useAgentVersionDetail( - mineDetailAgentId, - mineDetailVersionNo, - detailOpen && detailSource?.kind === "mine" - ); - - const detail: AgentDetailModalData | null | undefined = useMemo(() => { - if (detailSource?.kind === "repository" && repositoryDetail) { - return mapRepositoryListingDetail(repositoryDetail); - } - if (detailSource?.kind === "mine" && mineVersionDetail) { - return mapAgentVersionDetail(mineVersionDetail); - } - return detailSource ? undefined : null; - }, [detailSource, repositoryDetail, mineVersionDetail]); - - const isDetailLoading = - detailSource?.kind === "repository" - ? isRepositoryDetailLoading - : detailSource?.kind === "mine" - ? isMineVersionDetailLoading - : false; - - const isDetailError = - detailSource?.kind === "repository" - ? isRepositoryDetailError - : detailSource?.kind === "mine" - ? isMineVersionDetailError - : false; - - const isDetailFetching = - detailSource?.kind === "repository" - ? isRepositoryDetailFetching - : detailSource?.kind === "mine" - ? isMineVersionDetailFetching - : false; - - const refetchDetail = () => { - if (detailSource?.kind === "repository") { - refetchRepositoryDetail().catch(() => {}); - return; - } - if (detailSource?.kind === "mine") { - refetchMineVersionDetail().catch(() => {}); - } - }; - - const handleDetailClick = (listing: AgentRepositoryListingItem) => { - setDetailSource({ - kind: "repository", - agentRepositoryId: listing.agent_repository_id, - }); - }; - - const handleMineViewDetail = (agentId: number, versionNo: number) => { - setDetailSource({ kind: "mine", agentId, versionNo }); - }; - - const handleDetailClose = () => { - setDetailSource(null); - }; - - const handleCopyClick = (listing: AgentRepositoryListingItem) => { - setCopyListing(listing); - setCopyOpen(true); - }; - - const handleCopyClose = () => { - setCopyOpen(false); - setCopyListing(null); - }; - - const handleRepositoryTakeDown = (listing: AgentRepositoryListingItem) => - updateStatusMutation.mutateAsync({ - agentRepositoryId: listing.agent_repository_id, - status: "not_shared", - }); - - const updatingRepositoryId = - updateStatusMutation.isPending - ? updateStatusMutation.variables?.agentRepositoryId ?? null - : null; - - const listings = data?.items ?? []; - const repositoryPagination = data?.pagination; - const repositoryTotal = repositoryPagination?.total ?? 0; - const reviewListings = reviewData?.items ?? []; - const reviewPagination = reviewData?.pagination; - const reviewTotal = reviewPagination?.total ?? 0; - const mineAgents = mineData?.items ?? []; - const mineCounts = mineData?.counts ?? { all: 0, created: 0, others: 0 }; - const minePagination = mineData?.pagination; - const mineTotal = minePagination?.total ?? 0; - const deepLinkFallbackAgent = useMemo(() => { - const item = deepLinkMineData?.items?.[0]; - if (!item || isNewAgentPaddingItem(item)) { - return null; - } - return item; - }, [deepLinkMineData]); - const repositoryTabCount = repositoryCountData?.pagination?.total ?? 0; - const mineTabCount = mineCountData?.counts?.all ?? 0; + const repositoryTabCount = repositoryCountData?.pagination?.total; + const mineTabCount = isMineTab + ? (activeMineCount ?? mineCountPagination?.total) + : mineCountPagination?.total; const pendingReviewCount = reviewCountData?.pagination?.total ?? 0; - const handleRepositorySearchChange = (value: string) => { - setSearchQuery(value); - setRepositoryPage(1); - }; - return (
@@ -388,7 +91,7 @@ export default function AgentRepositoryPage() { exit="out" variants={pageVariants} transition={pageTransition} - className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10" + className="w-full h-full px-4 py-8 sm:px-6 sm:py-10 xl:px-16" >
@@ -412,36 +115,35 @@ export default function AgentRepositoryPage() { onValueChange={(value) => setTab(value as AgentRepositoryTab)} className="w-full" > - + {t("repository.page.tab.repository")} - - {repositoryTabCount} - + {repositoryTabCount != null ? ( + + {repositoryTabCount} + + ) : null} {t("agentRepository.page.tab.mine")} - - {mineTabCount} - + {mineTabCount != null ? ( + + {mineTabCount} + + ) : null} {isAdmin ? ( {t("repository.page.tab.review")} @@ -455,475 +157,24 @@ export default function AgentRepositoryPage() { - {isRepositoryTab ? ( - { - setRepositoryTagPredicates(value); - setRepositoryPage(1); - }} - isLoading={isLoading} - isError={isError} - isFetching={isFetching} - onRetry={() => refetch()} - listings={listings} - page={repositoryPage} - pageSize={REPOSITORY_PAGE_SIZE} - total={repositoryTotal} - onPageChange={setRepositoryPage} - onCopyClick={handleCopyClick} - onDetailClick={handleDetailClick} - showAdminMenu={isAdmin} - updatingRepositoryId={updatingRepositoryId} - onTakeDown={handleRepositoryTakeDown} - /> - ) : isReviewTab ? ( - refetchReview()} - page={reviewPage} - pageSize={REVIEW_PAGE_SIZE} - total={reviewTotal} - onPageChange={setReviewPage} - updatingRepositoryId={updatingRepositoryId} - onDetailClick={handleDetailClick} - onApprove={(listing, content) => - updateStatusMutation.mutateAsync({ - agentRepositoryId: listing.agent_repository_id, - status: "shared", - content, - }) - } - onReject={(listing, content) => - updateStatusMutation.mutateAsync({ - agentRepositoryId: listing.agent_repository_id, - status: "rejected", - content, - }) - } - /> - ) : isMineTab ? ( - { - setMineOwnership(ownership); - setMinePage(1); - }} - searchQuery={mineSearch} - onSearchChange={(value) => { - setMineSearch(value); - setMinePage(1); - }} - tagDefinitions={mineTagDefinitions ?? []} - tagPredicates={mineTagPredicates} - onTagPredicatesChange={(value) => { - setMineTagPredicates(value); - setMinePage(1); - }} - page={minePage} - pageSize={MINE_PAGE_SIZE} - total={mineTotal} - onPageChange={setMinePage} - isLoading={isMineLoading} - isError={isMineError} - isFetching={isMineFetching} - onRetry={() => refetchMine()} - onViewDetail={handleMineViewDetail} - reviewDeepLink={reviewDeepLink} - deepLinkFallbackAgent={deepLinkFallbackAgent} - deepLinkFallbackLoading={isDeepLinkMineLoading} - onReviewDeepLinkConsumed={handleReviewDeepLinkConsumed} - /> + + {isAdmin ? ( + ) : null} +
- refetchDetail()} - /> - { - if (!open) { - handleCopyClose(); - } else { - setCopyOpen(true); - } - }} - /> ); } - -function RepositoryView({ - searchQuery, - onSearchChange, - tagDefinitions, - tagPredicates, - onTagPredicatesChange, - isLoading, - isError, - isFetching, - onRetry, - listings, - page, - pageSize, - total, - onPageChange, - onCopyClick, - onDetailClick, - showAdminMenu, - updatingRepositoryId, - onTakeDown, -}: { - searchQuery: string; - onSearchChange: (value: string) => void; - tagDefinitions: TagDefinition[]; - tagPredicates: TagResourcePredicate[]; - onTagPredicatesChange: (value: TagResourcePredicate[]) => void; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - onRetry: () => void; - listings: AgentRepositoryListingItem[]; - page: number; - pageSize: number; - total: number; - onPageChange: (page: number) => void; - onCopyClick: (listing: AgentRepositoryListingItem) => void; - onDetailClick: (listing: AgentRepositoryListingItem) => void; - showAdminMenu: boolean; - updatingRepositoryId: number | null; - onTakeDown: (listing: AgentRepositoryListingItem) => Promise; -}) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - - const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; - const showPagination = !isLoading && !isError && totalPages > 1; - - const getListingTitle = (listing: AgentRepositoryListingItem) => - listing.display_name?.trim() || - listing.name?.trim() || - t("agentRepository.card.untitled"); - - const confirmTakeDown = (listing: AgentRepositoryListingItem) => { - const title = getListingTitle(listing); - - Modal.confirm({ - title: t("repository.listingStatus.confirmTakeDownTitle"), - content: t("repository.listingStatus.confirmTakeDownContent", { - name: title, - }), - okText: t("repository.listingStatus.takeDown"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: async () => { - try { - await onTakeDown(listing); - message.success(t("repository.mine.takeDownSuccess")); - } catch { - message.error(t("repository.mine.takeDownError")); - throw new Error("Take down failed"); - } - }, - }); - }; - - return ( -
-
-
- onSearchChange(e.target.value)} - placeholder={t("agentRepository.page.searchPlaceholder")} - prefix={} - className="h-11 rounded-xl" - allowClear - /> -
- -
- -

- {t("agentRepository.page.repositoryHint")} -

- - {isLoading ? ( -
- -
- ) : isError ? ( -
-

- {t("agentRepository.page.loadError")} -

- -
- ) : listings.length === 0 ? ( - - ) : ( - <> -
- {listings.map((listing) => ( -
- confirmTakeDown(listing)} - /> -
- ))} -
- - {showPagination ? ( -
- - {Array.from({ length: totalPages }, (_, index) => index + 1).map( - (pageNumber) => ( - - ) - )} - -
- ) : null} - - )} -
- ); -} - -function ReviewCenterView({ - listings, - currentUserEmail, - isLoading, - isError, - isFetching, - onRetry, - page, - pageSize, - total, - onPageChange, - updatingRepositoryId, - onDetailClick, - onApprove, - onReject, -}: { - listings: AgentRepositoryListingItem[]; - currentUserEmail?: string | null; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - onRetry: () => void; - page: number; - pageSize: number; - total: number; - onPageChange: (page: number) => void; - updatingRepositoryId: number | null; - onDetailClick: (listing: AgentRepositoryListingItem) => void; - onApprove: ( - listing: AgentRepositoryListingItem, - content?: string - ) => Promise; - onReject: ( - listing: AgentRepositoryListingItem, - content?: string - ) => Promise; -}) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const [reviewAction, setReviewAction] = - useState(null); - const [reviewListing, setReviewListing] = - useState(null); - - const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; - const showPagination = !isLoading && !isError && totalPages > 1; - - const getListingTitle = (listing: AgentRepositoryListingItem) => - listing.display_name?.trim() || - listing.name?.trim() || - t("agentRepository.card.untitled"); - - const closeReviewModal = () => { - setReviewAction(null); - setReviewListing(null); - }; - - const openReviewModal = ( - listing: AgentRepositoryListingItem, - action: AgentRepositoryReviewAction - ) => { - setReviewListing(listing); - setReviewAction(action); - }; - - const handleReviewConfirm = async (content?: string) => { - if (!reviewListing || !reviewAction) { - return; - } - - const title = getListingTitle(reviewListing); - const isApprove = reviewAction === "approve"; - - try { - await (isApprove - ? onApprove(reviewListing, content) - : onReject(reviewListing, content)); - message.success( - isApprove - ? t("repository.review.approveSuccess", { name: title }) - : t("repository.review.rejectSuccess", { name: title }) - ); - closeReviewModal(); - } catch { - message.error( - isApprove - ? t("repository.review.approveError") - : t("repository.review.rejectError") - ); - throw new Error("Review action failed"); - } - }; - - const isReviewModalLoading = - reviewListing != null && - updatingRepositoryId === reviewListing.agent_repository_id; - - return ( -
- {isLoading ? ( -
- -
- ) : isError ? ( -
-

- {t("repository.review.loadError")} -

- -
- ) : listings.length === 0 ? ( - - ) : ( - <> - openReviewModal(listing, "approve")} - onReject={(listing) => openReviewModal(listing, "reject")} - /> - - - - {showPagination ? ( -
- - {Array.from({ length: totalPages }, (_, index) => index + 1).map( - (pageNumber) => ( - - ) - )} - -
- ) : null} - - )} -
- ); -} diff --git a/frontend/app/[locale]/agent-space/review-center.tsx b/frontend/app/[locale]/agent-space/review-center.tsx new file mode 100644 index 0000000000..8359d21568 --- /dev/null +++ b/frontend/app/[locale]/agent-space/review-center.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { App, Button, Empty, Spin } from "antd"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { AgentDetail } from "@/components/agent/agent-detail"; +import { USER_ROLES } from "@/const/auth"; +import { + useAgentRepositoryListings, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { useRepositoryAgentDetail } from "@/hooks/agentRepository/useRepositoryAgentDetail"; + +import type { AgentRepositoryListingItem } from "@/types/agentRepository"; +import { ReviewAgentList } from "./components/ReviewAgentList"; +import { RepositoryAgentIcon } from "./components/RepositoryAgentIcon"; +import { + AgentRepositoryReviewConfirmModal, + type AgentRepositoryReviewAction, +} from "./components/AgentRepositoryReviewConfirmModal"; + +const REVIEW_PAGE_SIZE = 10; + +export function ReviewCenter({ active }: { active: boolean }) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { user } = useAuthorizationContext(); + const isAdmin = user?.role === USER_ROLES.ADMIN; + const currentUserEmail = user?.email; + const [page, setPage] = useState(1); + const pageSize = REVIEW_PAGE_SIZE; + const reviewListParams = useMemo( + () => ({ status: "pending_review" as const, page, page_size: pageSize }), + [page, pageSize] + ); + const { data, isLoading, isError, isFetching, refetch } = + useAgentRepositoryListings(reviewListParams, isAdmin && active); + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + const listings = data?.items ?? []; + const total = data?.pagination?.total ?? 0; + const updatingRepositoryId = updateStatusMutation.isPending + ? (updateStatusMutation.variables?.agentRepositoryId ?? null) + : null; + const [detailListing, setDetailListing] = + useState(null); + const { + detail, + repositoryDetail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + retry: refetchDetail, + } = useRepositoryAgentDetail(detailListing, active); + const [reviewAction, setReviewAction] = + useState(null); + const [reviewListing, setReviewListing] = + useState(null); + const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; + const showPagination = !isLoading && !isError && totalPages > 1; + + const closeReviewModal = () => { + setReviewAction(null); + setReviewListing(null); + }; + const handleReviewConfirm = async (content?: string) => { + if (!reviewListing || !reviewAction) return; + const title = + reviewListing.display_name?.trim() || + reviewListing.name?.trim() || + t("agentRepository.card.untitled"); + const isApprove = reviewAction === "approve"; + try { + await updateStatusMutation.mutateAsync({ + agentRepositoryId: reviewListing.agent_repository_id, + status: isApprove ? "shared" : "rejected", + content, + }); + message.success( + isApprove + ? t("repository.review.approveSuccess", { name: title }) + : t("repository.review.rejectSuccess", { name: title }) + ); + closeReviewModal(); + } catch { + message.error( + isApprove + ? t("repository.review.approveError") + : t("repository.review.rejectError") + ); + throw new Error("Review action failed"); + } + }; + const isReviewModalLoading = + reviewListing != null && + updatingRepositoryId === reviewListing.agent_repository_id; + + return ( +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ {t("repository.review.loadError")} +

+ +
+ ) : listings.length === 0 ? ( + + ) : ( + <> + setDetailListing(listing)} + onApprove={(listing) => { + setReviewListing(listing); + setReviewAction("approve"); + }} + onReject={(listing) => { + setReviewListing(listing); + setReviewAction("reject"); + }} + /> + + {showPagination ? ( +
+ + {Array.from({ length: totalPages }, (_, index) => index + 1).map( + (pageNumber) => ( + + ) + )} + +
+ ) : null} + + )} + setDetailListing(null)} + detail={detail} + agentIcon={ + detailListing ? ( + + ) : undefined + } + published + status={repositoryDetail?.status} + showRepositoryInfo + isLoading={isDetailLoading} + isError={isDetailError} + isFetching={isDetailFetching} + onRetry={refetchDetail} + /> +
+ ); +} diff --git a/frontend/app/[locale]/agent-tasks/page.tsx b/frontend/app/[locale]/agent-tasks/page.tsx index 6c885bde3d..2d4e91679c 100644 --- a/frontend/app/[locale]/agent-tasks/page.tsx +++ b/frontend/app/[locale]/agent-tasks/page.tsx @@ -26,8 +26,12 @@ import type { TableProps } from "antd"; import type { MenuProps } from "antd"; import { CalendarClock, - LoaderCircle, + ChevronDown, + ChevronRight, + Eye, + EyeOff, History, + LoaderCircle, MessageCirclePlus, MoreHorizontal, Pause, @@ -40,9 +44,11 @@ import { } from "lucide-react"; import { agentAutomationService } from "@/services/agentAutomationService"; +import { conversationService } from "@/services/conversationService"; import AutomationDateTimePicker from "@/features/agentAutomation/components/AutomationDateTimePicker"; import { getAutomationErrorMessage } from "@/features/agentAutomation/errorMessage"; import { formatDateTimeLocale } from "@/lib/date"; +import type { ApiMessage } from "@/types/conversation"; import type { AgentAutomationRun, AgentAutomationTask, @@ -230,6 +236,15 @@ export default function AgentTasksPage() { const [runLoading, setRunLoading] = useState(false); const [form] = Form.useForm(); const loadRequestIdRef = useRef(0); + // Inline expand state for the "View Result" panel on each task row. + const [expandedTaskIds, setExpandedTaskIds] = useState([]); + const [expandLoadingTaskIds, setExpandLoadingTaskIds] = useState([]); + const [latestRunByTask, setLatestRunByTask] = useState< + Record + >({}); + const [conversationMessagesByTask, setConversationMessagesByTask] = useState< + Record + >({}); const formatDateTime = (value?: string | null) => formatDateTimeLocale(value, i18n.language); @@ -462,6 +477,20 @@ export default function AgentTasksPage() { try { await agentAutomationService.run(task.task_id); message.success(t("agentAutomation.page.runSuccess")); + // Invalidate cached run result so the next expand refetches. + setLatestRunByTask((current) => { + const next = { ...current }; + delete next[task.task_id]; + return next; + }); + setConversationMessagesByTask((current) => { + const next = { ...current }; + delete next[task.task_id]; + return next; + }); + setExpandedTaskIds((current) => + current.filter((id) => id !== task.task_id) + ); } catch (error) { message.error( getAutomationErrorMessage(error, t, "agentAutomation.page.runFailed") @@ -471,6 +500,47 @@ export default function AgentTasksPage() { } }; + const handleToggleExpand = async (task: AgentAutomationTask) => { + const taskId = task.task_id; + const isExpanded = expandedTaskIds.includes(taskId); + if (isExpanded) { + setExpandedTaskIds((current) => + current.filter((id) => id !== taskId) + ); + return; + } + // Expand the row immediately; data will fill in after fetch. + setExpandedTaskIds((current) => [...current, taskId]); + // Skip refetch when cached data already exists. + if (latestRunByTask[taskId] !== undefined) return; + setExpandLoadingTaskIds((current) => [...current, taskId]); + try { + const [runsPage, conversationResponse] = await Promise.all([ + agentAutomationService.runs(taskId, { page: 1, pageSize: 1 }), + conversationService.getDetail(task.conversation_id), + ]); + const latestRun = runsPage.items[0] || null; + const messages = conversationResponse.data?.[0]?.message || []; + setLatestRunByTask((current) => ({ ...current, [taskId]: latestRun })); + setConversationMessagesByTask((current) => ({ + ...current, + [taskId]: messages, + })); + } catch (error: unknown) { + message.error( + getAutomationErrorMessage( + error, + t, + "agentAutomation.page.historyLoadFailed" + ) + ); + } finally { + setExpandLoadingTaskIds((current) => + current.filter((id) => id !== taskId) + ); + } + }; + const pauseTask = async (task: AgentAutomationTask) => { try { await agentAutomationService.pause(task.task_id); @@ -677,24 +747,66 @@ export default function AgentTasksPage() { ), }, { - title: t("agentAutomation.page.nextFireAt"), - dataIndex: "next_fire_at", + title: t("agentAutomation.page.lastFireAt"), + dataIndex: "last_fire_at", width: 220, render: (value) => formatDateTime(value), }, { title: t("agentAutomation.page.lastResult"), - width: 180, - render: (_, task) => ( -
-
{formatRunStatus(task.last_run_status)}
- {task.last_error && ( -
- {task.last_error} + width: 220, + render: (_, task) => { + const isExpanded = expandedTaskIds.includes(task.task_id); + const isLoading = expandLoadingTaskIds.includes(task.task_id); + const canExpand = Boolean(task.last_run_status); + return ( +
+
+ {formatRunStatus(task.last_run_status)} + {canExpand && ( + + +
- )} -
- ), + {task.last_error && ( +
+ {task.last_error} +
+ )} +
+ ); + }, }, { title: t("agentAutomation.page.actions"), @@ -799,6 +911,25 @@ export default function AgentTasksPage() { columns={columns} dataSource={tasks} onChange={handleTableChange} + expandable={{ + expandedRowKeys: expandedTaskIds, + onExpandedRowsChange: (keys) => + setExpandedTaskIds(keys.map((key) => Number(key))), + rowExpandable: (task) => Boolean(task.last_run_status), + expandedRowRender: (task) => ( + + ), + }} pagination={{ current: taskPage, pageSize: taskPageSize, @@ -1023,3 +1154,183 @@ export default function AgentTasksPage() {
); } + +interface RunResultPanelProps { + task: AgentAutomationTask; + run?: AgentAutomationRun | null; + messages?: ApiMessage[]; + loading: boolean; + formatDateTime: (value?: string | null) => string; + formatRunStatus: (status?: string | null) => string; + formatTriggerType: (triggerType: string) => string; + t: (key: string, options?: Record) => string; + locale: string; +} + +function RunResultPanel({ + task, + run, + messages, + loading, + formatDateTime, + formatRunStatus, + formatTriggerType, + t, + locale, +}: RunResultPanelProps) { + const [promptExpanded, setPromptExpanded] = useState(false); + + if (loading) { + return ( +
+ + {t("common.loading")} +
+ ); + } + + if (!run) { + return ( +
+ {t("agentAutomation.page.noRuns")} +
+ ); + } + + // Match the assistant message by assistant_message_id to retrieve the + // agent's output content from the conversation history. + const assistantMessage = run.assistant_message_id + ? messages?.find((item) => item.message_id === run.assistant_message_id) + : undefined; + + let agentOutput = ""; + if (assistantMessage) { + const raw = assistantMessage.message; + if (typeof raw === "string") { + agentOutput = raw; + } else if (Array.isArray(raw)) { + agentOutput = raw.map((unit) => unit.content || "").join(""); + } + } + + const durationSeconds = + run.duration_ms != null ? (run.duration_ms / 1000).toFixed(2) : null; + + const isFailed = run.status === "FAILED"; + + return ( +
+
+ + + {t("agentAutomation.page.status")}: + {" "} + + {formatRunStatus(run.status)} + + + + + {t("agentAutomation.page.trigger")}: + {" "} + {formatTriggerType(run.trigger_type)} + + {run.scheduled_fire_at && ( + + + {t("agentAutomation.page.scheduledFireAt")}: + {" "} + {formatDateTime(run.scheduled_fire_at)} + + )} + {run.actual_fire_at && ( + + + {t("agentAutomation.page.actualFireAt")}: + {" "} + {formatDateTime(run.actual_fire_at)} + + )} + {durationSeconds && ( + + + {t("agentAutomation.page.duration")}: + {" "} + {durationSeconds}s + + )} +
+ + {run.generated_prompt && ( +
+ + {promptExpanded && ( +
+              {run.generated_prompt}
+            
+ )} +
+ )} + + {isFailed && (run.error_code || run.error_message) && ( +
+
+ {t("agentAutomation.page.errorMessage")} +
+ {run.error_code && ( +
+ {t("agentAutomation.page.errorCode")}: {run.error_code} +
+ )} + {run.error_message && ( +
+ {run.error_message} +
+ )} +
+ )} + +
+
+ {t("agentAutomation.page.agentOutput")} +
+ {agentOutput ? ( +
+            {agentOutput}
+          
+ ) : ( +
+ {messages && messages.length > 0 + ? t("agentAutomation.page.noAgentOutput") + : t("agentAutomation.page.noConversationMessages")} +
+ )} +
+ +
+ + {t("agentAutomation.page.viewFullConversation")} + +
+
+ ); +} diff --git a/frontend/app/[locale]/agents/agent-config.tsx b/frontend/app/[locale]/agents/[agentId]/agent-config.tsx similarity index 94% rename from frontend/app/[locale]/agents/agent-config.tsx rename to frontend/app/[locale]/agents/[agentId]/agent-config.tsx index aa7d1873bd..4944200676 100644 --- a/frontend/app/[locale]/agents/agent-config.tsx +++ b/frontend/app/[locale]/agents/[agentId]/agent-config.tsx @@ -13,9 +13,11 @@ import { cn } from "@/lib/utils"; import { useAgentStore } from "@/stores/agentStore"; import { searchAgentInfo } from "@/services/agentConfigService"; import { getUnavailableReasonLabels } from "@/lib/agentLabelMapper"; +import { getTenantResourceLimitMessage } from "@/const/errorMessageI18n"; import { useSaveGuard } from "@/hooks/agent/useSaveGuard"; import { useAgentReadOnly } from "@/hooks/agent/useAgentReadOnly"; import { useNl2AgentFlow } from "@/contexts/nl2AgentFlow"; +import { buildDefaultAgentVersionName } from "@/lib/agentUsageGuide"; import AgentInfo from "./components/agent-info"; import AgentPrmopt from "./components/agent-prompt"; @@ -35,7 +37,7 @@ import GuardrailConfigContent, { import KnowledgeBaseConfig, { KnowledgeBaseConfigActions, } from "./components/knowledge-base-search"; -import AgentVersionPubulishModal from "./versions/AgentVersionPubulishModal"; +import AgentVersionPubulishModal from "../versions/AgentVersionPubulishModal"; import { ChevronRight, @@ -167,7 +169,8 @@ export default function AgentConfig({ const { t } = useTranslation("common"); const [form] = Form.useForm(); const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); - const [isRefreshingAvailability, setIsRefreshingAvailability] = useState(false); + const [isRefreshingAvailability, setIsRefreshingAvailability] = + useState(false); const [activeConfigTab, setActiveConfigTab] = useState("basic"); const [openSections, setOpenSections] = useState< @@ -203,7 +206,9 @@ export default function AgentConfig({ const { message } = App.useApp(); const saveError = useAgentStore((state) => state.saveError); const clearSaveError = useAgentStore((state) => state.clearSaveError); - const replaceServerSnapshot = useAgentStore((state) => state.replaceServerSnapshot); + const replaceServerSnapshot = useAgentStore( + (state) => state.replaceServerSnapshot + ); const handleRefreshAvailability = useCallback(async () => { if (!agentId || isRefreshingAvailability) return; @@ -213,7 +218,9 @@ export default function AgentConfig({ if (result.success && result.data) { replaceServerSnapshot(agentId, result.data); } else { - message.error(result.message || t("agent.config.refreshAvailabilityFailed")); + message.error( + result.message || t("agent.config.refreshAvailabilityFailed") + ); } } catch { message.error(t("agent.config.refreshAvailabilityFailed")); @@ -254,7 +261,10 @@ export default function AgentConfig({ lastScrolledRequestRef.current = requestKey; const frameId = window.requestAnimationFrame(() => { - const sectionRefs: Record> = { + const sectionRefs: Record< + ConfigSectionKey, + React.RefObject + > = { display_info: displayInfoSectionRef, role_model: roleModelSectionRef, tools: toolsSectionRef, @@ -333,7 +343,10 @@ export default function AgentConfig({ return; } - message.error(saveError); + message.error( + getTenantResourceLimitMessage(saveError, t) || + (saveError instanceof Error ? saveError.message : saveError) + ); clearSaveError(); }, [clearSaveError, message, saveError]); @@ -403,7 +416,14 @@ export default function AgentConfig({ + } + > +
+ {completionSyncFailed ? ( +
+ + {t( + "nl2agent.completion.syncFailed", + "The Agent was generated, but the form could not be refreshed." + )} + + +
+ ) : null} + +
+ + + + + + + +
+ ) : null + } + rightAction={ +
+ {agentInfo?.current_version_no && total > 0 ? ( +
+ + {t("agent.version.current")}: + setIsShowVersionManagePanel(true)} + > + {agentVersionDetail?.version.version_name || + `V${agentInfo.current_version_no}`} + + + / {t("agent.version.totalVersions", { count: total })} + +
+ ) : null} + +
+ } + > +
+ setIsDebugVisible((visible) => !visible)} + onPublished={handleAgentPublished} + /> +
+ + + + {t("agent.debug.compareMode")} + +
+ } + rightAction={ +
+ + +
+ } + > +
+ +
+ + + {isShowVersionManagePanel && ( + setIsShowVersionManagePanel(false)} + className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + > + + + } + > +
+ +
+
+ )} +
+ {isRequestedAgentLoading ? ( +
+ + {t("common.loading")} +
+ ) : null} + + + ); +} + +export default function AgentEditor() { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const router = useRouter(); + const { agentId } = useParams<{ agentId: string }>(); + const requestedAgentId = Number(agentId); + const isValidAgentId = + Number.isInteger(requestedAgentId) && requestedAgentId > 0; + const currentAgentId = useAgentStore((state) => state.currentAgentId); + const initialize = useAgentStore((state) => state.initialize); + const reset = useAgentStore((state) => state.reset); + const [isVersionManageOpen, setIsVersionManageOpen] = useState(false); + const isReturningRef = useRef(false); + const { agentInfo, refetch: refetchAgentInfo } = useAgentInfo(currentAgentId); + + useEffect(() => { + if (isReturningRef.current) return; + + if (!isValidAgentId) { + router.replace("/agents"); + return; + } + if (currentAgentId === requestedAgentId) return; + + void (async () => { + const result = await searchAgentInfo(requestedAgentId); + if (!result.success || !result.data) { + message.error( + result.message || t("agentConfig.agents.detailsLoadFailed") + ); + router.replace("/agents"); + return; + } + initialize(result.data); + })(); + }, [ + currentAgentId, + initialize, + isValidAgentId, + message, + requestedAgentId, + router, + t, + ]); + + if (!isValidAgentId) return null; + + return ( +
+
+ + +
+
+ + + +
+ setIsVersionManageOpen(false)} + footer={null} + > + + +
+ ); +} diff --git a/frontend/app/[locale]/agents/agent-selector-header.tsx b/frontend/app/[locale]/agents/agent-selector-header.tsx deleted file mode 100644 index fda8060ae2..0000000000 --- a/frontend/app/[locale]/agents/agent-selector-header.tsx +++ /dev/null @@ -1,534 +0,0 @@ -"use client"; - -import { useTranslation } from "react-i18next"; -import { App, Flex, Button, Dropdown, Tooltip, Col, Row, Input } from "antd"; -import { - Plus, - FileInput, - ChevronDown, - ChevronLeft, - Bot, - GitBranch, - Search, -} from "lucide-react"; -import { ExclamationCircleOutlined } from "@ant-design/icons"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - useParams, - usePathname, - useRouter, - useSearchParams, -} from "next/navigation"; -import { - searchAgentInfo, - clearAgentNewMark, -} from "@/services/agentConfigService"; - -import { Agent } from "@/types/agentConfig"; -import { useAgentStore } from "@/stores/agentStore"; -import { useQueryClient } from "@tanstack/react-query"; -import AgentImportWizard from "@/components/agent/AgentImportWizard"; -import CreateAgentModal from "@/components/agent/CreateAgentModal"; -import { - ImportAgentData, - openImportWizardWithFile, -} from "@/lib/agentImportUtils"; -import log from "@/lib/logger"; -import { useAgentList } from "@/hooks/agent/useAgentList"; -import AgentConfigActions from "./components/agent-config-actions"; - -interface AgentSelectorHeaderProps { - onToggleVersionManage: () => void; - isVersionManageVisible: boolean; - onAgentCreated: () => void; -} - -export default function AgentSelectorHeader({ - onToggleVersionManage, - isVersionManageVisible, - onAgentCreated, -}: AgentSelectorHeaderProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const params = useParams<{ locale: string }>(); - const locale = params.locale || "en"; - const showBackFromRepository = true; - const queryClient = useQueryClient(); - const waitForAutosave = useAgentStore((state) => state.waitForIdle); - - // Resolve tenant from auth (matches AgentManageComp / published_list; keeps ASSET_OWNER merge) - const { agents, isSuccess: hasLoadedAgents } = useAgentList(""); - - // Store state - const currentAgentId = useAgentStore((state) => state.currentAgentId); - const initialize = useAgentStore((state) => state.initialize); - const reset = useAgentStore((state) => state.reset); - - // Dropdown open state - const [dropdownOpen, setDropdownOpen] = useState(false); - const [agentSearch, setAgentSearch] = useState(""); - const requestedAgentIdRef = useRef(null); - - // Import wizard state - const [importWizardVisible, setImportWizardVisible] = useState(false); - const [importWizardData, setImportWizardData] = - useState(null); - const [createAgentModalVisible, setCreateAgentModalVisible] = useState(false); - - // Get current selected agent - const currentAgent = agents.find( - (agent: Agent) => - currentAgentId !== null && String(agent.id) === String(currentAgentId) - ); - - // Handle import agent - const handleImportAgent = async () => { - await openImportWizardWithFile({ - onSuccess: (agentData) => { - setImportWizardData(agentData); - setImportWizardVisible(true); - }, - message: message, - t: t, - log: log, - }); - }; - - const loadAgent = useCallback( - async (agentId: number) => { - if (agentId === null) return; - - const agent = agents.find((a: Agent) => String(a.id) === String(agentId)); - if (!agent || currentAgentId === Number(agent.id)) return; - - const selectedAgentId = Number(agent.id); - - // Clear NEW mark when agent is selected for editing - if (agent.is_new === true) { - try { - const res = await clearAgentNewMark(agent.id); - if (!res?.success) { - log.warn("Failed to clear NEW mark on select:", res); - queryClient.invalidateQueries({ queryKey: ["agents"] }); - } - } catch (err) { - log.error("Failed to clear NEW mark on select:", err); - } - } - - if (currentAgentId !== null) { - await waitForAutosave(); - } - - // Load and set agent - try { - const result = await searchAgentInfo(selectedAgentId); - if (requestedAgentIdRef.current !== selectedAgentId) { - return; - } - if (result.success && result.data) { - initialize(result.data); - } else { - message.error( - result.message || t("agentConfig.agents.detailsLoadFailed") - ); - } - } catch (error) { - log.error("Failed to load agent detail:", error); - message.error(t("agentConfig.agents.detailsLoadFailed")); - } - }, - [ - agents, - clearAgentNewMark, - currentAgentId, - initialize, - log, - message, - queryClient, - t, - waitForAutosave, - ] - ); - - // The URL is the single source of truth for selection. Loading the Agent is - // handled by the synchronization effect below after navigation completes. - const handleSelectAgent = useCallback( - (agentId: number | null) => { - if (agentId === null) return; - - const agent = agents.find((a: Agent) => String(a.id) === String(agentId)); - if (!agent || currentAgentId === Number(agent.id)) return; - - const nextSearchParams = new URLSearchParams(searchParams.toString()); - nextSearchParams.set("agent_id", String(agent.id)); - router.replace(`${pathname}?${nextSearchParams.toString()}`); - }, - [agents, currentAgentId, pathname, router, searchParams] - ); - - useEffect(() => { - const rawAgentId = searchParams.get("agent_id"); - const parsedAgentId = rawAgentId ? Number(rawAgentId) : null; - - // Keep the selected Agent in sync with the URL and the current user's list. - // This also prevents an Agent loaded under a previous account from remaining - // in the store after the account switch clears or invalidates agent_id. - if (parsedAgentId === null) { - requestedAgentIdRef.current = null; - if (currentAgentId !== null) reset(); - return; - } - - if (!Number.isInteger(parsedAgentId) || parsedAgentId <= 0) { - requestedAgentIdRef.current = null; - if (currentAgentId !== null) reset(); - - const nextSearchParams = new URLSearchParams(searchParams.toString()); - nextSearchParams.delete("agent_id"); - router.replace( - nextSearchParams.size > 0 - ? `${pathname}?${nextSearchParams.toString()}` - : pathname - ); - return; - } - - // An empty list is also the query's loading state, so wait for a successful - // response before treating an Agent as unavailable to the current user. - if (!hasLoadedAgents) return; - - if (!agents.some((agent: Agent) => Number(agent.id) === parsedAgentId)) { - requestedAgentIdRef.current = null; - if (currentAgentId !== null) reset(); - - const nextSearchParams = new URLSearchParams(searchParams.toString()); - nextSearchParams.delete("agent_id"); - router.replace( - nextSearchParams.size > 0 - ? `${pathname}?${nextSearchParams.toString()}` - : pathname - ); - return; - } - - if (requestedAgentIdRef.current === parsedAgentId) { - return; - } - - requestedAgentIdRef.current = parsedAgentId; - if (currentAgentId !== parsedAgentId) { - void loadAgent(parsedAgentId); - } - }, [ - agents, - currentAgentId, - hasLoadedAgents, - loadAgent, - pathname, - reset, - router, - searchParams, - ]); - - const filteredAgents = useMemo(() => { - const query = agentSearch.trim().toLowerCase(); - if (!query) return agents; - - return agents.filter((agent: Agent) => - [agent.display_name, agent.name, agent.description].some((value) => - String(value || "") - .toLowerCase() - .includes(query) - ) - ); - }, [agentSearch, agents]); - - // Dropdown menu items (only agents) - const agentMenuItems = filteredAgents.flatMap( - (agent: Agent, index: number) => { - const isAvailable = agent.is_available !== false; - const displayName = agent.display_name || ""; - const name = agent.name || ""; - - const agentItem = { - key: `agent-${agent.id}`, - label: ( -
- - {/* Row 1: Name + Status */} -
-
- - {!isAvailable && ( - { - const reasons = agent.unavailable_reasons || []; - if (reasons.includes("agent_not_found")) { - return t("subAgentPool.tooltip.unavailableAgent"); - } else if (reasons.includes("tool_unavailable")) { - return t("toolPool.tooltip.unavailableTool"); - } else if (reasons.includes("duplicate_name")) { - return t("agent.error.nameExists", { name }); - } else if ( - reasons.includes("duplicate_display_name") - ) { - return t("agent.error.displayNameExists", { - displayName, - }); - } else if (reasons.includes("model_unavailable")) { - return t("agent.error.modelUnavailable"); - } - return t("subAgentPool.tooltip.unavailableAgent"); - })()} - > - - - )} - {agent.is_new && ( - - - - {t("space.new", "NEW")} - - - - )} - {displayName && ( - {displayName} - )} - -
-
-
- {/* Row 2: Description */} -
- {agent.description} -
-
-
- ), - onClick: () => handleSelectAgent(Number(agent.id)), - }; - - // Add divider after each item except the last one - const divider = - index < filteredAgents.length - 1 - ? { key: `divider-${agent.id}`, type: "divider" as const } - : null; - - return divider ? [agentItem, divider] : [agentItem]; - } - ); - - const handleBackToRepository = async () => { - await waitForAutosave(); - router.push(`/${locale}/agent-space?tab=mine`); - }; - - const handleCreateAgent = async () => { - await waitForAutosave(); - setCreateAgentModalVisible(true); - }; - - const handleImportComplete = async (agentId: number) => { - setImportWizardVisible(false); - setImportWizardData(null); - await queryClient.invalidateQueries({ queryKey: ["agents"] }); - - const result = await searchAgentInfo(agentId); - if (!result.success || !result.data) { - message.error(result.message || t("agent.error.fetchAgentList")); - return; - } - - initialize({ ...result.data, permission: "EDIT" }); - const nextSearchParams = new URLSearchParams(searchParams.toString()); - nextSearchParams.set("agent_id", String(agentId)); - router.replace(`${pathname}?${nextSearchParams.toString()}`); - }; - - const handleAgentCreated = async ({ agentId }: { agentId: number }) => { - setCreateAgentModalVisible(false); - queryClient.invalidateQueries({ queryKey: ["agents"] }); - const result = await searchAgentInfo(agentId); - if (!result.success || !result.data) { - message.error(result.message || t("agent.error.fetchAgentList")); - return; - } - initialize({ ...result.data, permission: "EDIT" }); - router.replace(`${pathname}?agent_id=${agentId}`); - message.success(t("subAgentPool.button.create")); - onAgentCreated(); - }; - - return ( - <> -
- - {/* Left column: Agent Config */} - - - {showBackFromRepository ? ( - - - - - - - - - - -
- - setCreateAgentModalVisible(false)} - onCreated={handleAgentCreated} - /> - - {/* Import Wizard Modal */} - { - setImportWizardVisible(false); - setImportWizardData(null); - }} - initialData={importWizardData} - onImportComplete={handleImportComplete} - /> - - ); -} diff --git a/frontend/app/[locale]/agents/agent-version.tsx b/frontend/app/[locale]/agents/agent-version.tsx index 401c31df24..b9598adefa 100644 --- a/frontend/app/[locale]/agents/agent-version.tsx +++ b/frontend/app/[locale]/agents/agent-version.tsx @@ -128,7 +128,7 @@ export default function AgentVersionManage({ ) : ( -
+
{agentVersionList.map((version) => ( (
+ > +
+ )} {agentId !== null && ( ) : ( -
+
{t( "a2a.service.getServerSettingsFailed", "Failed to load A2A settings" diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillManagement.tsx deleted file mode 100644 index 52df331737..0000000000 --- a/frontend/app/[locale]/agents/components/agentConfig/SkillManagement.tsx +++ /dev/null @@ -1,414 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { SkillGroup, Skill, SkillParam } from "@/types/agentConfig"; -import { Badge, message, Tabs, Tooltip } from "antd"; -import { useAgentStore } from "@/stores/agentStore"; -import { useAgentReadOnly } from "@/hooks/agent/useAgentReadOnly"; -import { useSkillList } from "@/hooks/agent/useSkillList"; -import { Eye, Pencil, Trash2, Settings } from "lucide-react"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { - deleteSkill, - fetchSkillInstances, -} from "@/services/agentConfigService"; -import log from "@/lib/logger"; -import SkillDetailModal from "./SkillDetailModal"; -import SkillConfigModal from "./skill/SkillConfigModal"; -import SkillRowContent from "./skill/SkillRowContent"; -import { - hasMissingRequiredSkillConfig, - requiresSkillConfigOnSelection, - withEffectiveSkillConfig, -} from "./skill/utils"; - -interface SkillManagementProps { - skillGroups: SkillGroup[]; - currentAgentId?: number | undefined; - isReadOnly?: boolean; - onEditSkill?: (skill: Skill) => void; - displayMode?: "tabs" | "list"; - dialogZIndex?: number; -} - -export default function SkillManagement({ - skillGroups, - currentAgentId, - isReadOnly: isReadOnlyProp, - onEditSkill, - displayMode = "tabs", - dialogZIndex = 1000, -}: SkillManagementProps) { - const { t } = useTranslation("common"); - const { confirm } = useConfirmModal(); - - const agentIsReadOnly = useAgentReadOnly(); - const isReadOnly = Boolean(isReadOnlyProp) || agentIsReadOnly; - - const originalSelectedSkills = useAgentStore( - (state) => state.editedAgent?.skills ?? [] - ); - const originalSelectedSkillIdsSet = new Set( - originalSelectedSkills.map((skill) => Number(skill.skill_id)) - ); - - const updateSkills = useAgentStore((state) => state.updateSkills); - - const { groupedSkills, invalidate } = useSkillList(); - - const [activeTabKey, setActiveTabKey] = useState(""); - const [selectedSkill, setSelectedSkill] = useState(null); - const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); - const [configModalSkill, setConfigModalSkill] = useState(null); - const [configModalOpen, setConfigModalOpen] = useState(false); - const [skillInstanceMap, setSkillInstanceMap] = useState< - Record> - >({}); - - useEffect(() => { - if (groupedSkills.length > 0 && !activeTabKey) { - setActiveTabKey(groupedSkills[0].key); - } - }, [groupedSkills, activeTabKey]); - - // Fetch per-agent skill instances to get saved config_values - useEffect(() => { - if (!currentAgentId) { - setSkillInstanceMap({}); - return; - } - - let cancelled = false; - (async () => { - try { - const result = await fetchSkillInstances(Number(currentAgentId), 0); - if (result.success && result.data) { - const map: Record> = {}; - for (const instance of result.data) { - if ( - instance.config_values && - typeof instance.config_values === "object" - ) { - map[instance.skill_id] = instance.config_values; - } - } - if (!cancelled) { - setSkillInstanceMap(map); - } - } - } catch (err) { - log.error("Failed to fetch skill instances:", err); - } - })(); - - return () => { - cancelled = true; - }; - }, [currentAgentId]); - - const handleSkillClick = (skill: Skill) => { - if (isReadOnly) return; - - const currentSkills = useAgentStore.getState().editedAgent?.skills ?? []; - const isCurrentlySelected = currentSkills.some( - (s) => Number(s.skill_id) === Number(skill.skill_id) - ); - - if (isCurrentlySelected) { - const newSelectedSkills = currentSkills.filter( - (s) => Number(s.skill_id) !== Number(skill.skill_id) - ); - updateSkills(newSelectedSkills); - } else { - // In uninstantiated mode, skillInstanceMap is empty — preserve skill.config_values (template defaults) - const savedConfigValues = skillInstanceMap[skill.skill_id] || null; - const skillWithValues = withEffectiveSkillConfig( - skill, - savedConfigValues - ); - const hasRequiredParams = hasMissingRequiredSkillConfig(skillWithValues); - const alwaysRequiresConfig = - requiresSkillConfigOnSelection(skillWithValues); - - if (hasRequiredParams || alwaysRequiresConfig) { - setConfigModalSkill(skillWithValues); - setConfigModalOpen(true); - } else { - updateSkills([...currentSkills, skillWithValues]); - } - } - }; - - const handleInfoClick = (skill: Skill, e: React.MouseEvent) => { - e.stopPropagation(); - if (!isReadOnly && skill.permission === "EDIT" && onEditSkill) { - onEditSkill(skill); - return; - } - setSelectedSkill(skill); - setIsDetailModalOpen(true); - }; - - const handleDeleteClick = async (skill: Skill, e: React.MouseEvent) => { - e.stopPropagation(); - confirm({ - title: t("skillManagement.delete.confirmTitle"), - content: t("skillManagement.delete.confirmContent", { - skillName: skill.name, - }), - okText: t("common.confirm"), - cancelText: t("common.cancel"), - onOk: async () => { - const result = await deleteSkill(skill.name); - if (result.success) { - message.success(t("skillManagement.delete.success")); - const currentSkills = - useAgentStore.getState().editedAgent?.skills ?? []; - const updatedSkills = currentSkills.filter( - (s) => Number(s.skill_id) !== Number(skill.skill_id) - ); - updateSkills(updatedSkills); - invalidate(); - } else { - message.error(result.message || t("skillManagement.delete.failed")); - } - }, - }); - }; - - const handleConfigClick = (skill: Skill, e: React.MouseEvent) => { - e.stopPropagation(); - const savedConfigValues = skillInstanceMap[skill.skill_id] || null; - // In uninstantiated mode, skillInstanceMap is empty — preserve skill.config_values (template defaults) - setConfigModalSkill(withEffectiveSkillConfig(skill, savedConfigValues)); - setConfigModalOpen(true); - }; - - const handleSkillConfigSave = (skill: Skill, savedParams: SkillParam[]) => { - // Build the config_values dict from saved params - const configValues: Record = {}; - for (const p of savedParams) { - configValues[p.name] = p.value; - } - - // Update skillInstanceMap so the map stays in sync with saved data - setSkillInstanceMap((prev) => ({ - ...prev, - [skill.skill_id]: configValues, - })); - - // Update the skill in the edited agent's skills list with the new params - const currentSkills = useAgentStore.getState().editedAgent?.skills ?? []; - const existingIndex = currentSkills.findIndex( - (s) => Number(s.skill_id) === Number(skill.skill_id) - ); - - const updatedSkill: Skill = { - ...skill, - config_values: configValues, - }; - - let updatedSkills: Skill[]; - if (existingIndex >= 0) { - // Replace existing entry with updated config - updatedSkills = [...currentSkills]; - updatedSkills[existingIndex] = updatedSkill; - } else { - // Skill not yet in list — add it (came from forced modal open) - updatedSkills = [...currentSkills, updatedSkill]; - } - updateSkills(updatedSkills); - }; - - const renderSkillRows = (skills: Skill[]) => ( -
    - {skills.map((skill) => { - const isSelected = originalSelectedSkillIdsSet.has( - Number(skill.skill_id) - ); - const canEditSkill = - !isReadOnly && skill.permission === "EDIT" && Boolean(onEditSkill); - const hasConfigurableParams = - Array.isArray(skill.config_schemas) && - skill.config_schemas.length > 0; - - return ( -
  • -
    handleSkillClick(skill)} - onKeyDown={(event) => { - if ( - !isReadOnly && - (event.key === "Enter" || event.key === " ") - ) { - event.preventDefault(); - handleSkillClick(skill); - } - }} - > - -
    - - - {displayMode !== "list" ? ( - - ) : null} -
    -
    -
  • - ); - })} -
- ); - - const tabItems = skillGroups.map((group) => { - const selectedCount = group.skills.filter((skill) => - originalSelectedSkillIdsSet.has(Number(skill.skill_id)) - ).length; - - return { - key: group.key, - label: ( - - - - {group.label} - - {selectedCount > 0 && ( - - )} - - - ), - children: renderSkillRows(group.skills), - }; - }); - - return ( -
- {skillGroups.length === 0 ? ( -
- {t("skillPool.noSkills")} -
- ) : displayMode === "list" ? ( - renderSkillRows(skillGroups[0].skills) - ) : ( - - )} - - { - setIsDetailModalOpen(false); - setSelectedSkill(null); - }} - /> - - {configModalSkill && ( - { - setConfigModalOpen(false); - setConfigModalSkill(null); - }} - onSave={(params) => { - if (configModalSkill) { - handleSkillConfigSave(configModalSkill, params); - } - }} - skill={configModalSkill} - initialParams={configModalSkill.config_schemas || []} - currentAgentId={currentAgentId} - zIndex={dialogZIndex} - maskClosable - /> - )} -
- ); -} diff --git a/frontend/app/[locale]/agents/components/agentInfo/DebugConfig.tsx b/frontend/app/[locale]/agents/components/agentInfo/DebugConfig.tsx deleted file mode 100644 index f4b4f01cc3..0000000000 --- a/frontend/app/[locale]/agents/components/agentInfo/DebugConfig.tsx +++ /dev/null @@ -1,1127 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Paperclip, X, AlertCircle } from "lucide-react"; - -import { Input, Select, Switch, message as antMessage } from "antd"; - -import { conversationService } from "@/services/conversationService"; -import { ChatMessageType, FilePreview } from "@/types/chat"; -import { handleStreamResponse } from "@/app/chat/streaming/chatStreamHandler"; -import { ChatModelSelector } from "@/app/chat/components/chatModelSelector"; -import { MESSAGE_ROLES, chatConfig } from "@/const/chatConfig"; -import log from "@/lib/logger"; -import { - getCachedDebugError, - cacheDebugError, - clearCachedDebugError, -} from "@/lib/agentDebugErrorCache"; -import { - cleanupAttachmentUrls, - buildMinioFilePayload, -} from "@/lib/chat/chatAttachmentUtils"; -import { - getFileExtension, - getFileIcon, - MAX_FILE_COUNT, - MAX_FILE_SIZE, -} from "@/lib/chat/fileIconUtils"; -import { safeUUID } from "@/lib/utils"; -import { useModelList } from "@/hooks/model/useModelList"; -import { useAgentConfigStore } from "@/stores/agentConfigStore"; -import { useAgentInfo } from "@/hooks/agent/useAgentInfo"; -import DebugMessageList from "./DebugMessageList"; -import DebugOptimizeModal from "./DebugOptimizeModal"; -import { useCompareStream } from "./useCompareStream"; -import { RuntimeMetadataEditor } from "@/components/chat/RuntimeMetadataEditor"; - -// Check if a file type is supported -const isSupportedFile = (extension: string, fileType: string): boolean => { - const isImage = fileType.startsWith("image/") || chatConfig.imageExtensions.includes(extension); - const isDocument = chatConfig.documentExtensions.includes(extension) || fileType === "application/pdf" || fileType.includes("officedocument"); - const isSupportedTextFile = chatConfig.supportedTextExtensions.includes(extension) || fileType === "text/csv" || fileType === "text/plain"; - const isMedia = fileType.startsWith("audio/") || fileType.startsWith("video/") || chatConfig.audioExtensions.includes(extension) || chatConfig.videoExtensions.includes(extension); - return isImage || isDocument || isSupportedTextFile || isMedia; -}; - -// Agent debugging component Props interface -interface AgentDebuggingProps { - onStop: () => void; - onClear: () => void; - inputQuestion: string; - onInputChange: (value: string) => void; - onSend: () => void; - isStreaming: boolean; - isCompareStreaming?: boolean; - messages: ChatMessageType[]; - onOptimizeReply?: (params: { - userQuestion: string; - assistantAnswer: string; - history: Array<{ role: string; content: string }>; - }) => void; - comparePanel?: React.ReactNode; - showCompare?: boolean; - onOpenCompare?: () => void; - compareDisabled?: boolean; - isCompareMode?: boolean; - attachments: FilePreview[]; - onFileSelect: (files: File[]) => void; - onRemoveAttachment: (id: string) => void; - modelIds?: number[]; - modelNames?: string[]; - selectedModelId?: number | null; - onModelSelect?: (modelId: number | null) => void; -} - -// Main component Props interface -interface DebugConfigProps { - agentId?: number | null; // Make agentId an optional prop -} - - -/** - * Agent debugging component - */ -function AgentDebugging({ - onStop, - onClear, - inputQuestion, - onInputChange, - onSend, - isStreaming, - isCompareStreaming = false, - messages, - onOptimizeReply, - comparePanel, - showCompare, - onOpenCompare, - compareDisabled, - isCompareMode, - attachments, - onFileSelect, - onRemoveAttachment, - modelIds, - modelNames, - selectedModelId, - onModelSelect, -}: AgentDebuggingProps & { - modelIds: number[]; - modelNames: string[]; - selectedModelId: number | null; - onModelSelect: (modelId: number | null) => void; -}) { - const { t } = useTranslation(); - const isInputDisabled = isStreaming || (isCompareMode && isCompareStreaming); - const fileInputRef = useRef(null); - const [errorMessage, setErrorMessage] = useState(null); - - // Handle file input change - const handleFileInputChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - onFileSelect(Array.from(files)); - e.target.value = ""; - }; - - // Auto-dismiss error message - useEffect(() => { - if (errorMessage) { - const timer = setTimeout(() => setErrorMessage(null), 3000); - return () => clearTimeout(timer); - } - }, [errorMessage]); - - return ( -
-
- {isCompareMode ? ( -
- {comparePanel} -
- ) : ( -
- {/* Message display area */} - -
- )} - - {/* Attachment preview chips */} - {attachments.length > 0 && ( -
- {attachments.map((attachment) => ( -
- {attachment.type === chatConfig.filePreviewTypes.image && attachment.previewUrl ? ( - {attachment.file.name} - ) : ( - - {getFileIcon(attachment.file.name, attachment.file.type, 16)} - - )} - - {attachment.file.name} - - -
- ))} -
- )} - - {/* Error message */} - {errorMessage && ( -
- - {errorMessage} -
- )} - -
- {/* Paperclip file upload button */} - - onInputChange(e.target.value)} - placeholder={t("agent.debug.placeholder")} - onPressEnter={onSend} - disabled={isInputDisabled} - className="flex-1" - /> - {/* Model selector for debug mode */} - {!isCompareMode && modelIds && modelIds.length > 0 && ( - {})} - disabled={isInputDisabled} - /> - )} - - {isCompareMode - ? t("agent.debug.compareMode", "Compare mode") - : t("agent.debug.defaultMode", "Default mode")} - - {showCompare && ( -
- - - {t("agent.debug.compare", "Compare")} - -
- )} - {/* Clear history button */} - - {isStreaming ? ( - - ) : ( - - )} -
-
-
- ); -} - -/** - * Debug configuration main component - */ -export default function DebugConfig({ agentId }: DebugConfigProps) { - const parsedAgentId = - agentId === undefined || agentId === null || Number.isNaN(Number(agentId)) - ? undefined - : Number(agentId); - const { t } = useTranslation(); - const [messages, setMessages] = useState([]); - const [isStreaming, setIsStreaming] = useState(false); - const [inputQuestion, setInputQuestion] = useState(""); - const [selectedModelId, setSelectedModelId] = useState(null); - const [runtimeMetadata, setRuntimeMetadata] = useState< - Record - >({}); - const { availableLlmModels } = useModelList(); - const { agentInfo } = useAgentInfo(parsedAgentId); - const editedAgent = useAgentConfigStore((state) => state.editedAgent); - const timeoutRef = useRef(null); - const abortControllerRef = useRef(null); - const prevAgentIdRef = useRef(undefined); - // Maintain an independent step ID counter per Agent - const stepIdCounter = useRef<{ current: number }>({ current: 0 }); - - const [debugOptimizeOpen, setDebugOptimizeOpen] = useState(false); - const [debugOptimizeSelected, setDebugOptimizeSelected] = useState; - }>(null); - const [compareOriginalPrompt, setCompareOriginalPrompt] = useState(""); - const [compareOptimizedPrompt, setCompareOptimizedPrompt] = useState(""); - - const [isComparePanelOpen, setIsComparePanelOpen] = useState(false); - const [compareLeftModelId, setCompareLeftModelId] = useState(null); - const [compareRightModelId, setCompareRightModelId] = useState(null); - const hasMultipleLlmModels = availableLlmModels.length >= 2; - - // Attachment state - const [attachments, setAttachments] = useState([]); - const [fileUrls, setFileUrls] = useState>({}); - - // Derive debug model selector options from search_info's model_ids, - // resolving display names against the already-loaded model list. - const debugModelIds = useMemo( - () => - (agentInfo?.model_ids ?? []).filter((id) => - availableLlmModels.some((model) => model.id === id) - ), - [agentInfo, availableLlmModels] - ); - const debugModelNames = useMemo(() => { - return debugModelIds.map((id: number) => { - const model = availableLlmModels.find((m) => m.id === id); - return model?.displayName || model?.name || String(id); - }); - }, [debugModelIds, availableLlmModels]); - const defaultModelId = useMemo(() => { - const ids = debugModelIds; - if (ids.length === 0) return null; - return ids[0]; - }, [debugModelIds]); - - // Initialize selectedModelId when agent info becomes available - useEffect(() => { - setSelectedModelId((current) => - current !== null && debugModelIds.includes(current) - ? current - : (defaultModelId ?? null) - ); - }, [debugModelIds, defaultModelId]); - - const comparePersistenceKey = - parsedAgentId === undefined - ? "debug-compare:anonymous" - : `debug-compare:agent-${parsedAgentId}`; - const comparePersistenceFallbackKeys = - parsedAgentId === undefined ? [] : ["debug-compare:anonymous"]; - - const { - leftMessages: compareLeftMessages, - rightMessages: compareRightMessages, - isCompareStreaming, - compareStreamingLeft, - compareStreamingRight, - runCompare, - stopCompare, - resetCompareState, - } = useCompareStream({ - t, - buildRunParams: ({ side, question, conversationId, history, minio_files }) => ({ - query: question, - conversation_id: conversationId, - is_set: true, - history, - is_debug: true, - agent_id: parsedAgentId, - model_id: side === "left" ? compareLeftModelId ?? undefined : compareRightModelId ?? undefined, - minio_files, - }), - persistenceKey: comparePersistenceKey, - persistenceFallbackKeys: comparePersistenceFallbackKeys, - getHistory: () => - messages - .filter((msg) => msg.isComplete !== false && msg.content?.trim()) - .map((msg) => ({ role: msg.role, content: msg.content })), - }); - - // Reset debug state when agentId changes - useEffect(() => { - const normalizedAgentId = parsedAgentId ?? null; - const previousAgentId = prevAgentIdRef.current; - prevAgentIdRef.current = normalizedAgentId; - const hasSwitchedAgent = - previousAgentId !== undefined && - previousAgentId !== null && - normalizedAgentId !== null && - previousAgentId !== normalizedAgentId; - - // Clear debug history - setMessages([]); - // Reset step ID counter - stepIdCounter.current.current = 0; - // Clear attachment state - setAttachments([]); - setFileUrls({}); - // Stop both frontend and backend when switching agent (debug mode) - const hasActiveStream = isStreaming || abortControllerRef.current !== null; - if (hasActiveStream) { - handleStop(); - } - - // Check for cached error from previous debug session - if (agentId !== undefined && agentId !== null && !isNaN(Number(agentId))) { - const cachedError = getCachedDebugError(Number(agentId)); - if (cachedError) { - // Restore the cached error as a message with a step containing the error - const errorMessage: ChatMessageType = { - id: Date.now().toString(), - role: MESSAGE_ROLES.ASSISTANT, - content: cachedError, - timestamp: new Date(), - isComplete: true, - error: cachedError, - // Add a step with the error info so TaskWindow can display it - steps: [ - { - id: "error-step", - title: "Error", - content: cachedError, - expanded: true, - metrics: null, - thinking: { content: "", expanded: true }, - code: { content: "", expanded: true }, - output: { content: cachedError, expanded: true }, - contents: [ - { - id: "error-content", - type: "error" as const, - content: cachedError, - expanded: true, - timestamp: Date.now(), - subType: "error", - }, - ], - }, - ], - }; - setMessages([errorMessage]); - } - } - - // Reset compare state only when switching to a different agent. - // On initial mount/re-mount with the same agent, keep persisted compare history. - if (hasSwitchedAgent) { - setIsComparePanelOpen(false); - stopCompare(); - resetCompareState(); - } - }, [agentId]); - - useEffect(() => { - if (!hasMultipleLlmModels) { - setCompareLeftModelId(null); - setCompareRightModelId(null); - return; - } - - const agentConfiguredIds = debugModelIds.filter((id) => - availableLlmModels.some((m) => m.id === id) - ); - - const defaultModelId = - debugModelIds.length > 0 - ? debugModelIds[0] - : null; - const leftModelId = - defaultModelId && agentConfiguredIds.includes(defaultModelId) - ? defaultModelId - : agentConfiguredIds[0] ?? availableLlmModels[0]?.id ?? null; - const rightModelId = - availableLlmModels.find((m) => m.id !== leftModelId)?.id ?? null; - - setCompareLeftModelId((prev) => { - if (prev && agentConfiguredIds.includes(prev)) return prev; - return leftModelId; - }); - setCompareRightModelId((prev) => { - if (prev && availableLlmModels.some((m) => m.id === prev) && prev !== leftModelId) { - return prev; - } - return rightModelId; - }); - }, [availableLlmModels, debugModelIds, hasMultipleLlmModels]); - - // Reset timeout timer - const resetTimeout = () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - setIsStreaming(false); - }, 30000); // 30 seconds timeout - }; - - // Handle stop function - const handleStop = async () => { - // Stop agent_run immediately - if (abortControllerRef.current) { - try { - abortControllerRef.current.abort(t("agent.debug.userStop")); - } catch (error) { - log.error(t("agent.debug.cancelError"), error); - } - abortControllerRef.current = null; - } - - // Clear timeout timer - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - - // Immediately update frontend state - setIsStreaming(false); - - // Try to stop backend agent run for debug mode - try { - await conversationService.stop(-1); // Use -1 for debug mode - } catch (error) { - log.error(t("agent.debug.stopError"), error); - // This is expected if no agent is running for debug mode - } - - // Manually update messages, clear thinking state - setMessages((prev) => { - const newMessages = [...prev]; - const lastMsg = newMessages[newMessages.length - 1]; - if (lastMsg && lastMsg.role === MESSAGE_ROLES.ASSISTANT) { - lastMsg.isComplete = true; - lastMsg.thinking = undefined; // Explicitly clear thinking state - lastMsg.content = t("agent.debug.stopped"); - } - return newMessages; - }); - }; - - // Clear local history and reset the step counter - const handleClearHistory = async () => { - if (isComparePanelOpen) { - if (isCompareStreaming) { - stopCompare(); - } - resetCompareState(); - } else { - setMessages([]); - stepIdCounter.current.current = 0; - } - setInputQuestion(""); - // Clear attachment state - setAttachments([]); - setFileUrls({}); - // Clear cached error for this agent - if (agentId !== undefined && agentId !== null && !isNaN(Number(agentId))) { - clearCachedDebugError(Number(agentId)); - } - }; - - - // Process test question - const handleTestQuestion = async (question: string) => { - setIsStreaming(true); - - // Create new AbortController for this request - abortControllerRef.current = new AbortController(); - - // Upload attachments (if any) and build the minio_files payload. - // Debug mode requests per-file descriptions via preprocessing (withDescription = true). - const attachmentPayload = await buildMinioFilePayload( - attachments, - fileUrls, - question, - abortControllerRef.current?.signal, - true, - t - ); - if (attachmentPayload.error) { - antMessage.error(`${t("chatPreprocess.fileUploadFailed")} ${attachmentPayload.error}`); - setIsStreaming(false); - abortControllerRef.current = null; - return; - } - const { messageAttachments, minioFiles } = attachmentPayload; - - // Add user message - const userMessage: ChatMessageType = { - id: Date.now().toString(), - role: MESSAGE_ROLES.USER, - content: question, - timestamp: new Date(), - attachments: messageAttachments.length > 0 ? messageAttachments : undefined, - }; - - // Add assistant message (initial state) - const assistantMessage: ChatMessageType = { - id: (Date.now() + 1).toString(), - role: MESSAGE_ROLES.ASSISTANT, - content: "", - timestamp: new Date(), - isComplete: false, - }; - - setMessages((prev) => [...prev, userMessage, assistantMessage]); - - // Clear attachments after adding them to the message - setAttachments([]); - setFileUrls({}); - - // Ensure agent_id is a number - let agentIdValue: number | undefined = undefined; - if (agentId !== undefined && agentId !== null) { - agentIdValue = Number(agentId); - if (isNaN(agentIdValue)) { - agentIdValue = undefined; - } - } - - try { - // Call agent_run with AbortSignal - // Debug mode does NOT pass conversation_id: backend skips auto-create - // when is_debug=True, so no conversation row is created for this run. - const reader = await conversationService.runAgent( - { - query: question, - history: messages - .filter(msg => msg.isComplete !== false) // Only pass completed messages - .map(msg => { - const historyItem: any = { - role: msg.role, - content: - msg.role === MESSAGE_ROLES.ASSISTANT - ? msg.finalAnswer?.trim() || msg.content || "" - : msg.content || "", - }; - // Include attachment info for historical messages - if (msg.attachments && msg.attachments.length > 0) { - historyItem.minio_files = msg.attachments.map((att) => ({ - object_name: att.object_name || "", - name: att.name, - type: att.type, - size: att.size, - url: att.url || "", - presigned_url: att.presigned_url || "", - description: att.description || "", - })); - } - return historyItem; - }), - is_debug: true, // Add debug mode flag - agent_id: agentIdValue, // Use the properly parsed agent_id - minio_files: minioFiles.length > 0 ? minioFiles : undefined, - model_id: selectedModelId ?? undefined, - metadata: runtimeMetadata, - }, - abortControllerRef.current.signal - ); // Pass AbortSignal - - if (!reader) throw new Error(t("agent.debug.nullResponse")); - - // Process stream response - await handleStreamResponse( - reader as ReadableStreamDefaultReader, - setMessages, - resetTimeout, - stepIdCounter.current, - () => {}, // setIsSwitchedConversation - Debug mode does not need - () => {}, // onConversationCreated - Debug mode does not auto-create conversations - true, // isDebug: true for debug mode - t - ); - } catch (error) { - // If user actively canceled, don't show error message - const err = error as Error; - const isUserStop = - err.name === "AbortError" || err.message === t("agent.debug.userStop"); - if (isUserStop) { - setMessages((prev) => { - const newMessages = [...prev]; - const lastMsg = newMessages[newMessages.length - 1]; - if (lastMsg && lastMsg.role === MESSAGE_ROLES.ASSISTANT) { - lastMsg.content = t("agent.debug.stopped"); - lastMsg.isComplete = true; - lastMsg.thinking = undefined; // Explicitly clear thinking state - } - return newMessages; - }); - } else { - log.error(t("agent.debug.streamError"), error); - const errorMessage = - error instanceof Error - ? error.message - : t("agent.debug.processError"); - - // Cache the error for future debug sessions - if (agentIdValue !== undefined) { - cacheDebugError(agentIdValue, errorMessage); - } - - setMessages((prev) => { - const newMessages = [...prev]; - const lastMsg = newMessages[newMessages.length - 1]; - if (lastMsg && lastMsg.role === MESSAGE_ROLES.ASSISTANT) { - lastMsg.content = errorMessage; - lastMsg.isComplete = true; - lastMsg.error = errorMessage; - } - return newMessages; - }); - } - } finally { - setIsStreaming(false); - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - if (abortControllerRef.current) { - abortControllerRef.current = null; - } - } - }; - - const handleCompare = async () => { - const question = inputQuestion.trim(); - if (!question) return; - if (!compareLeftModelId || !compareRightModelId) return; - if (compareLeftModelId === compareRightModelId) return; - setInputQuestion(""); - - // Upload attachments (if any) and build the minio_files payload. - // Compare mode skips per-file descriptions (withDescription = false). - const attachmentPayload = await buildMinioFilePayload( - attachments, - fileUrls, - question, - undefined, - false, - t - ); - if (attachmentPayload.error) { - antMessage.error(`${t("chatPreprocess.fileUploadFailed")} ${attachmentPayload.error}`); - return; - } - const { messageAttachments, minioFiles } = attachmentPayload; - - // Clear attachments after preparing them - setAttachments([]); - setFileUrls({}); - - await runCompare( - question, - minioFiles.length > 0 ? minioFiles : undefined, - messageAttachments.length > 0 ? messageAttachments : undefined - ); - }; - - const comparePanel = isComparePanelOpen ? ( -
-
-
- - {t("agent.debug.compareDefault", "Default model")} - - setCompareRightModelId(value)} - options={availableLlmModels - .filter((model) => model.id !== compareLeftModelId) - .map((model) => ({ - value: model.id, - label: model.displayName || model.name, - }))} - placeholder={t("agent.debug.compareSelectModel", "Select model")} - disabled={isCompareStreaming} - /> -
-
- - {isCompareStreaming && ( -
- -
- )} - -
-
-
- {(() => { - const model = availableLlmModels.find((m) => m.id === compareLeftModelId); - return model ? model.displayName || model.name : editedAgent.model || "-"; - })()} -
- -
-
-
- {(() => { - const model = availableLlmModels.find((m) => m.id === compareRightModelId); - return model ? model.displayName || model.name : "-"; - })()} -
- -
-
-
- ) : null; - - const toggleComparePanel = () => { - const nextOpen = !isComparePanelOpen; - setIsComparePanelOpen(nextOpen); - if (nextOpen) { - if (isStreaming || abortControllerRef.current) { - handleStop(); - } - // Enter compare mode: clear default chat history and compare outputs - setMessages([]); - stepIdCounter.current.current = 0; - } else if (isCompareStreaming) { - stopCompare(); - } - }; - - // Handle file selection with validation - const handleFileSelect = (files: File[]) => { - // Check file count limit - if (attachments.length + files.length > MAX_FILE_COUNT) { - antMessage.error(t("chatInput.fileCountExceedsLimit", { count: MAX_FILE_COUNT })); - return; - } - - const newAttachments: FilePreview[] = []; - - for (const file of files) { - // Check single file size limit - if (file.size > MAX_FILE_SIZE) { - antMessage.error(t("chatInput.fileSizeExceedsLimit", { name: file.name })); - return; - } - - const fileId = safeUUID(); - const extension = getFileExtension(file.name); - - const isImage = file.type.startsWith("image/") || chatConfig.imageExtensions.includes(extension); - const isSupported = isSupportedFile(extension, file.type); - - if (!isSupported) { - antMessage.error(t("chatInput.unsupportedFileType", { name: file.name })); - return; - } - - const previewUrl = isImage ? URL.createObjectURL(file) : undefined; - - newAttachments.push({ - id: fileId, - file, - type: isImage ? chatConfig.filePreviewTypes.image : chatConfig.filePreviewTypes.file, - fileType: file.type, - extension, - previewUrl, - }); - - // Create local URL for non-image files - if (!isImage) { - const fileUrl = URL.createObjectURL(file); - setFileUrls((prev) => ({ ...prev, [fileId]: fileUrl })); - } - } - - if (newAttachments.length > 0) { - setAttachments([...attachments, ...newAttachments]); - } - }; - - // Handle removing an attachment - const handleRemoveAttachment = (id: string) => { - const attachment = attachments.find((a) => a.id === id); - if (attachment?.previewUrl) { - URL.revokeObjectURL(attachment.previewUrl); - } - const fileUrl = fileUrls[id]; - if (fileUrl) { - URL.revokeObjectURL(fileUrl); - setFileUrls((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - } - setAttachments(attachments.filter((a) => a.id !== id)); - }; - - // Hold the latest attachment state for the unmount-only cleanup below. - // Kept in a ref because the cleanup effect has `[]` deps and would otherwise - // capture a stale (initial) snapshot of attachments/fileUrls. - const attachmentStateRef = useRef({ attachments, fileUrls }); - useEffect(() => { - attachmentStateRef.current = { attachments, fileUrls }; - }); - - // Revoke any remaining object URLs when the component unmounts. - // NOTE: deps are intentionally `[]`. With `[attachments, fileUrls]` here, React - // would run the cleanup with the *previous* closure on every state change, - // revoking URLs of attachments that are still in the list and breaking their - // previews. Per-attachment revocation on removal is handled in handleRemoveAttachment; - // this effect only acts as a teardown safety net for anything still attached at unmount. - useEffect(() => { - return () => { - cleanupAttachmentUrls( - attachmentStateRef.current.attachments, - attachmentStateRef.current.fileUrls - ); - }; - }, []); - - const handleSend = () => { - if (!inputQuestion.trim()) return; - if (isComparePanelOpen) { - handleCompare(); - } else { - handleTestQuestion(inputQuestion); - setInputQuestion(""); - } - }; - - const handleOpenOptimize = (params: { - userQuestion: string; - assistantAnswer: string; - history: Array<{ role: string; content: string }>; - }) => { - if (!parsedAgentId) return; - if (debugModelIds.length === 0) return; - - const duty = (editedAgent?.duty_prompt || "").trim(); - const constraint = (editedAgent?.constraint_prompt || "").trim(); - const fewShots = (editedAgent?.few_shots_prompt || "").trim(); - - const originalFullPrompt = [ - "# 智能体角色", - duty, - "", - "# 使用要求", - constraint, - "", - "# 示例", - fewShots, - ] - .filter((part) => part !== undefined) - .join("\n") - .trim(); - - setCompareOriginalPrompt(originalFullPrompt); - setCompareOptimizedPrompt(""); - - setDebugOptimizeSelected(params); - setDebugOptimizeOpen(true); - }; - - const handleOptimized = (params: { - originalFullPrompt: string; - optimizedFullPrompt: string; - }) => { - setCompareOriginalPrompt(params.originalFullPrompt || ""); - setCompareOptimizedPrompt(params.optimizedFullPrompt || ""); - }; - - const handleApplyOptimizedPrompt = (optimizedFullPrompt?: string) => { - const optimized = (optimizedFullPrompt || compareOptimizedPrompt || "").trim(); - if (!optimized) { - return; - } - - const normalized = optimized - .replace(/\r\n/g, "\n") - .replace(/^#\s*智能体角色\s*$/gm, "# Duty") - .replace(/^#\s*使用要求\s*$/gm, "# Constraint") - .replace(/^#\s*示例\s*$/gm, "# FewShots"); - - const pickSection = (header: "Duty" | "Constraint" | "FewShots"): string => { - const headerRegex = new RegExp(`^#\\s*${header}\\s*$`, "gm"); - const matches = [...normalized.matchAll(headerRegex)]; - const current = matches[0]; - if (!current) return ""; - - const start = current.index + current[0].length; - const rest = normalized.slice(start); - const nextHeaderMatch = rest.match(/^#\s*(Duty|Constraint|FewShots)\s*$/m); - const end = nextHeaderMatch?.index ?? rest.length; - return rest.slice(0, end).trim(); - }; - - const duty = pickSection("Duty"); - const constraint = pickSection("Constraint"); - const fewShots = pickSection("FewShots"); - - const updateAgentConfig = useAgentConfigStore.getState().updateAgentConfig; - - updateAgentConfig({ - ...(duty ? { duty_prompt: duty } : {}), - ...(constraint ? { constraint_prompt: constraint } : {}), - ...(fewShots ? { few_shots_prompt: fewShots } : {}), - }); - // Close optimize modal after applying. - setDebugOptimizeOpen(false); - setDebugOptimizeSelected(null); - setCompareOriginalPrompt(""); - setCompareOptimizedPrompt(""); - }; - - return ( -
- { - setDebugOptimizeOpen(false); - setDebugOptimizeSelected(null); - setCompareOriginalPrompt(""); - setCompareOptimizedPrompt(""); - }} - onOptimized={handleOptimized} - onApply={(optimizedFullPrompt) => { - setCompareOptimizedPrompt(optimizedFullPrompt || ""); - handleApplyOptimizedPrompt(optimizedFullPrompt); - }} - /> - - {editedAgent?.allow_chat_metadata && ( -
- -
- )} - -
- -
-
- ); -} diff --git a/frontend/app/[locale]/agents/components/agentInfo/DebugOptimizeModal.tsx b/frontend/app/[locale]/agents/components/agentInfo/DebugOptimizeModal.tsx deleted file mode 100644 index 1aa35e817a..0000000000 --- a/frontend/app/[locale]/agents/components/agentInfo/DebugOptimizeModal.tsx +++ /dev/null @@ -1,221 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { App, Button, Input, Modal, Space, Spin, Typography } from "antd"; -import { optimizePromptFromDebug } from "@/services/promptService"; - -const { TextArea } = Input; -const { Paragraph, Text } = Typography; - -export interface DebugOptimizeModalProps { - open: boolean; - agentId: number; - modelId: number; - userQuestion: string; - assistantAnswer: string; - history: Array<{ role: string; content: string }>; - initialOriginalFullPrompt?: string; - onCancel: () => void; - onOptimized: (params: { originalFullPrompt: string; optimizedFullPrompt: string }) => void; - onApply: (optimizedFullPrompt: string) => void; - applying?: boolean; -} - -export default function DebugOptimizeModal({ - open, - agentId, - modelId, - userQuestion, - assistantAnswer, - history, - initialOriginalFullPrompt, - onCancel, - onOptimized, - onApply, - applying, -}: DebugOptimizeModalProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - - const [feedback, setFeedback] = useState(""); - const [isOptimizing, setIsOptimizing] = useState(false); - - const [originalFullPrompt, setOriginalFullPrompt] = useState(""); - const [optimizedFullPrompt, setOptimizedFullPrompt] = useState(""); - const [displayedContent, setDisplayedContent] = useState(""); - - // Section header mapping: English -> Chinese - const headerMap: Record = { - "# Duty": "#智能体角色", - "# Constraint": "#使用要求", - "# FewShots": "#示例", - }; - - const mapHeadersToChinese = (text: string) => { - let result = text; - for (const [en, zh] of Object.entries(headerMap)) { - result = result.split(en).join(zh); - } - return result; - }; - - useEffect(() => { - if (!open) { - setFeedback(""); - setIsOptimizing(false); - setOriginalFullPrompt(""); - setOptimizedFullPrompt(""); - setDisplayedContent(""); - return; - } - - setFeedback(""); - setIsOptimizing(false); - setDisplayedContent(""); - // Show original prompt immediately when opening the modal. - setOriginalFullPrompt((prev) => prev || initialOriginalFullPrompt || ""); - // Keep original prompt visible while waiting for new optimized result. - setOptimizedFullPrompt(""); - }, [open, agentId, modelId]); - - const handleOk = async () => { - if (!feedback.trim()) { - message.error(t("systemPrompt.optimize.feedbackRequired")); - return; - } - - setIsOptimizing(true); - try { - const data = await optimizePromptFromDebug({ - agent_id: agentId, - model_id: modelId, - feedback: feedback.trim(), - selected: { - user_question: userQuestion, - assistant_answer: assistantAnswer, - }, - history, - }); - - const original = data?.original_full_prompt || ""; - const fullText = mapHeadersToChinese(data?.optimized_full_prompt || ""); - - setOriginalFullPrompt(original); - setOptimizedFullPrompt(fullText); - setDisplayedContent(fullText); - - // Ensure modal stays open and does not reset prompts. - setIsOptimizing(false); - - onOptimized({ - originalFullPrompt: original, - optimizedFullPrompt: fullText, - }); - } catch (e: any) { - message.error(e?.message || t("systemPrompt.optimize.error")); - } finally { - setIsOptimizing(false); - } - }; - - return ( - - - - - - } - destroyOnHidden - > -
- - {t( - "agent.debug.optimizeHint", - "Select a reply, provide feedback, and we will optimize the full system prompt." - )} - - -
- {t("systemPrompt.optimize.feedbackLabel")} -