fix(phase2): restore agent streams and persist extension installations

This commit is contained in:
2026-09-06 02:21:27 +08:00
parent 9497519e8b
commit 7001794a22
35 changed files with 870 additions and 39 deletions
+85
View File
@@ -0,0 +1,85 @@
"""Offline reference scoring. No inference, uploads or fabricated reference labels."""
from __future__ import annotations
import math
import unicodedata
def edit_distance(reference, hypothesis):
if len(reference) * len(hypothesis) > 20_000_000:
raise ValueError('Text comparison exceeds 20 million cells; score shorter annotated recordings separately')
row = list(range(len(hypothesis) + 1))
for i, a in enumerate(reference, 1):
next_row = [i]
for j, b in enumerate(hypothesis, 1):
next_row.append(min(next_row[-1] + 1, row[j] + 1, row[j-1] + (a != b)))
row = next_row
return row[-1]
def validate_segments(items):
if isinstance(items, dict):
items = items.get('segments')
if not isinstance(items, list) or len(items) > 10000:
raise ValueError('segments must be an array with at most 10000 entries')
items = [dict(item, start=item.get('start', item.get('start_time')), end=item.get('end', item.get('end_time'))) for item in items]
for item in items:
start, end = item['start'], item['end']
if not all(isinstance(value, (int, float)) and math.isfinite(value) for value in (start, end)) or start < 0 or end <= start:
raise ValueError('Each segment needs finite 0 <= start < end times in seconds')
if not isinstance(item.get('text', ''), str):
raise ValueError('Segment text must be a string')
return sorted(items, key=lambda item: (item['start'], item['end']))
def speaker_score(reference, hypothesis):
if not reference or any(not isinstance(item.get('speaker'), str) or not item['speaker'] for item in reference + hypothesis):
return {'status': 'unavailable', 'reason': 'Reference and hypothesis speaker labels are required'}
refs = sorted({item['speaker'] for item in reference})
hyps = sorted({item['speaker'] for item in hypothesis})
count = max(len(refs), len(hyps))
if count > 12:
raise ValueError('Speaker scoring supports at most 12 speaker IDs per recording')
boundaries = sorted({item[key] for item in reference + hypothesis for key in ('start', 'end')})
weights = [[0.0] * count for _ in range(count)]
denominator = missed = false_alarm = common = 0.0
for start, end in zip(boundaries, boundaries[1:]):
r = {item['speaker'] for item in reference if item['start'] < end and item['end'] > start}
h = {item['speaker'] for item in hypothesis if item['start'] < end and item['end'] > start}
duration = end - start
denominator += duration * len(r)
missed += duration * max(0, len(r) - len(h))
false_alarm += duration * max(0, len(h) - len(r))
common += duration * min(len(r), len(h))
for a in r:
for b in h:
weights[refs.index(a)][hyps.index(b)] += duration
# Exact maximum-weight one-to-one mapping, padded with silent dummy speakers.
dp = {0: 0.0}
for index in range(count):
next_dp = {}
for mask, score in dp.items():
for column in range(count):
if not mask & (1 << column):
key = mask | (1 << column)
next_dp[key] = max(next_dp.get(key, -1), score + weights[index][column])
dp = next_dp
confusion = max(0.0, common - max(dp.values()))
return {'status': 'scored', 'collar_seconds': 0, 'overlap_included': True,
'reference_speaker_seconds': denominator, 'missed_seconds': missed,
'false_alarm_seconds': false_alarm, 'confusion_seconds': confusion,
'der': (missed + false_alarm + confusion) / denominator if denominator else None}
def score(reference, hypothesis):
reference, hypothesis = validate_segments(reference), validate_segments(hypothesis)
if not reference:
raise ValueError('A non-empty human reference is required')
texts = [' '.join(unicodedata.normalize('NFC', item.get('text', '')) for item in items) for items in (reference, hypothesis)]
metrics = {}
for name, units in [('cer', [[c for c in text if not c.isspace()] for text in texts]), ('wer', [text.split() for text in texts])]:
expected, actual = units
edits = edit_distance(expected, actual)
metrics[name] = {'edits': edits, 'reference_units': len(expected), 'rate': edits / len(expected) if expected else None}
return {'text': metrics, 'speaker': speaker_score(reference, hypothesis),
'normalization': 'NFC; punctuation/case retained; CER ignores whitespace; WER uses whitespace tokens',
'quality_gate': 'not_evaluated', 'reference_segments': len(reference), 'hypothesis_segments': len(hypothesis)}
+7 -1
View File
@@ -5,6 +5,7 @@ from app.agent.builtin_tools import register_builtin_tools
from app.contracts import ModelCapability, ProviderConfig, ProviderType
from app.config import BACKEND_DIR, get_settings
from app.extensions import PluginRuntime, SkillRuntime
from app.extensions.installed import InstalledRuntime
from app.extensions.mcp_registry import McpServerRegistry
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.routing import ModelRoutingService
@@ -64,6 +65,8 @@ def build_container() -> ApplicationContainer:
)
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
plugins.enable("text-tools")
plugins = InstalledRuntime(plugins, 'plugin', settings.data_dir)
plugins.restore()
mcp_servers = McpServerRegistry(
tools,
@@ -75,7 +78,10 @@ def build_container() -> ApplicationContainer:
skills = SkillRuntime(tools)
skills.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant")
skills.enable("knowledge-assistant")
if not skills.get("knowledge-assistant").missing_dependencies:
skills.enable("knowledge-assistant")
skills = InstalledRuntime(skills, 'skill', settings.data_dir)
skills.restore()
policy = PermissionPolicy()
permissions = PermissionManager(policy)
+2 -2
View File
@@ -25,7 +25,7 @@ def invalid(message: str) -> ApiError:
return ApiError(422, 'EXTENSION_ZIP_INVALID', message)
def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path], T]) -> T:
def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path], T], *, managed_install: Callable[[Path, Path], T] | None = None) -> T:
if len(data) > MAX_ZIP_BYTES:
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 文件不能超过 10 MiB。')
if kind not in ('skill', 'plugin'):
@@ -89,7 +89,7 @@ def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path],
if len(children) != 1 or not children[0].is_dir() or not (children[0] / manifest).is_file():
raise invalid(f'ZIP 根目录或唯一顶层文件夹中须包含 {manifest}')
root = children[0]
return install(root)
return managed_install(root, destination) if managed_install else install(root)
except BaseException as error:
shutil.rmtree(destination)
if isinstance(error, ExtensionError):
+172
View File
@@ -0,0 +1,172 @@
"""Local installation journal. Only explicitly managed ZIP roots may be removed."""
from __future__ import annotations
import hashlib
import json
import logging
import shutil
import sqlite3
import threading
from contextlib import contextmanager
from pathlib import Path
from app.extensions.errors import ExtensionError
log = logging.getLogger(__name__)
def package_digest(root: Path) -> str:
digest = hashlib.sha256()
total = 0
files = sorted(root.rglob('*'))
for path in files:
if path.is_symlink():
raise ValueError('Package links cannot be restored automatically')
if not path.is_file() or '__pycache__' in path.parts or path.suffix == '.pyc':
continue
total += path.stat().st_size
if total > 50 * 1024 * 1024 or len(files) > 4096:
raise ValueError('Package exceeds restoration limits')
digest.update(path.relative_to(root).as_posix().encode())
digest.update(b'\0')
digest.update(path.read_bytes())
return digest.hexdigest()
class InstalledRuntime:
def __init__(self, runtime, kind: str, data_dir: Path):
self.runtime = runtime
self.kind = kind
self.storage = (data_dir / 'extension-packages').resolve()
self.path = data_dir / 'extension-installations.sqlite3'
self.path.parent.mkdir(parents=True, exist_ok=True)
self.lock = threading.RLock()
self.restoring = False
self.restore_errors: list[dict[str, str]] = []
with self._db() as db:
db.execute('CREATE TABLE IF NOT EXISTS installations (kind TEXT, id TEXT, data TEXT, PRIMARY KEY(kind,id))')
@contextmanager
def _db(self):
db = sqlite3.connect(self.path)
try:
with db:
yield db
finally:
db.close()
def __getattr__(self, name):
return getattr(self.runtime, name)
def _read(self, identifier):
with self._db() as db:
row = db.execute('SELECT data FROM installations WHERE kind=? AND id=?', (self.kind, identifier)).fetchone()
return json.loads(row[0]) if row else {}
def _write(self, identifier, data):
with self._db() as db:
db.execute('INSERT OR REPLACE INTO installations VALUES (?,?,?)', (self.kind, identifier, json.dumps(data)))
def _save(self, identifier, managed_root=None, *, installing=False):
if self.restoring:
return
record = self.runtime._records[identifier]
item = self.runtime.get(identifier)
previous = self._read(identifier)
self._write(identifier, {
'path': str(record.package_path), 'digest': package_digest(record.package_path) if installing or not previous else previous['digest'],
'enabled': item.enabled, 'permissions': getattr(item, 'granted_permissions', []),
'managed_root': (str(managed_root) if managed_root else None) if installing else previous.get('managed_root'),
'removed': False,
})
def install(self, package_path, *, managed_root=None):
with self.lock:
root = Path(package_path).resolve()
package_digest(root) # Check before changing runtime state.
if managed_root is not None:
owned = Path(managed_root).resolve()
if owned.parent != self.storage or not root.is_relative_to(owned):
raise ValueError('Invalid managed package root')
item = self.runtime.install(root)
identifier = getattr(item.manifest, f'{self.kind}_id')
try:
self._save(identifier, managed_root, installing=True)
except Exception:
self.runtime.uninstall(identifier)
raise
self.restore_errors = [error for error in self.restore_errors if error['id'] != identifier]
return item
def enable(self, identifier):
with self.lock:
# Changed packages must be reinstalled to re-parse their declarations.
saved = self._read(identifier)
root = self.runtime._record(identifier).package_path
if saved and saved.get('digest') != package_digest(root):
raise ExtensionError('EXTENSION_PACKAGE_CHANGED', 'Package changed; reinstall and review its permissions.', status_code=409)
item = self.runtime.enable(identifier)
self._save(identifier)
return item
def disable(self, identifier):
with self.lock:
item = self.runtime.disable(identifier)
self._save(identifier)
return item
def set_permissions(self, identifier, permissions):
with self.lock:
item = self.runtime.set_permissions(identifier, permissions)
self._save(identifier)
return item
def uninstall(self, identifier, *args, **kwargs):
with self.lock:
saved = self._read(identifier)
self.runtime.uninstall(identifier, *args, **kwargs)
saved['removed'] = True
self._write(identifier, saved)
self._cleanup(saved)
def _cleanup(self, saved):
raw = saved.get('managed_root')
if not raw:
return # Directory installs belong to the user.
path = Path(raw)
if path.is_symlink() or path.resolve().parent != self.storage:
raise ValueError('Refusing to remove an unmanaged package directory')
if path.exists():
shutil.rmtree(path)
def restore(self):
with self.lock:
with self._db() as db:
rows = db.execute('SELECT id,data FROM installations WHERE kind=?', (self.kind,)).fetchall()
self.restoring = True
try:
for identifier, raw in rows:
try:
saved = json.loads(raw)
if identifier in self.runtime._records:
self.runtime.uninstall(identifier)
if saved.get('removed'):
self._cleanup(saved)
continue
root = Path(saved['path'])
if not root.is_dir() or package_digest(root) != saved['digest']:
raise ValueError('Package missing or changed; reinstall and review permissions')
item = self.runtime.install(root)
actual_id = getattr(item.manifest, f'{self.kind}_id')
if actual_id != identifier:
self.runtime.uninstall(actual_id)
raise ValueError('Package identity changed')
if self.kind == 'plugin':
self.runtime.set_permissions(identifier, saved.get('permissions', []))
if saved.get('enabled'):
self.runtime.enable(identifier)
except Exception as error:
self.restore_errors.append({'kind': self.kind, 'id': identifier, 'message': 'Package recovery failed; inspect the package and reinstall or enable it again.'})
log.warning('Extension restore failed: %s/%s (%s)', self.kind, identifier, type(error).__name__)
finally:
self.restoring = False
+1 -1
View File
@@ -90,7 +90,7 @@ class SkillRuntime:
self._records: dict[str, _SkillRecord] = {}
def install(self, package_path: str | Path) -> Skill:
# TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态
# 应用层 InstalledRuntime 负责安装记录和可信包恢复;此类保留独立可测试的运行时
root = _package_dir(package_path)
raw = _read_yaml(root / "skill.yaml")
if "id" in raw and "skill_id" not in raw:
+7 -2
View File
@@ -673,13 +673,18 @@ async def read_extension_zip(request: Request) -> bytes:
@router.post('/skills/install-zip', response_model=Skill, status_code=202, tags=['Skills'])
async def install_skill_zip(request: Request) -> Skill:
data = await read_extension_zip(request)
return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install))
return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install, managed_install=lambda root, owned: container.skills.install(root, managed_root=owned)))
@router.post('/plugins/install-zip', response_model=Plugin, status_code=202, tags=['Plugins'])
async def install_plugin_zip(request: Request) -> Plugin:
data = await read_extension_zip(request)
return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install))
return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install, managed_install=lambda root, owned: container.plugins.install(root, managed_root=owned)))
@router.get('/extensions/restore-errors', tags=['Plugins', 'Skills'])
async def extension_restore_errors():
return {'items': container.plugins.restore_errors + container.skills.restore_errors}
@router.post(
@@ -6,6 +6,7 @@ commands:
locations:
- command_palette
- context_menu
- toolbar
when:
- editor.has_selection
context:
+8
View File
@@ -0,0 +1,8 @@
"""Development reload watches application code, never imported extension packages."""
from pathlib import Path
import uvicorn
if __name__ == '__main__':
backend = Path(__file__).resolve().parents[1]
uvicorn.run('app.main:app', host='127.0.0.1', port=8000, app_dir=str(backend),
reload=True, reload_dirs=[str(backend / 'app')])
+49
View File
@@ -0,0 +1,49 @@
"""Explicit, bounded connection smoke against an already configured local Provider.
Defaults to a plan. --execute performs one test request, never reads credentials.
The output deliberately keeps untested protocol scenarios pending.
"""
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
SCENARIOS = ['model_discovery', 'tool_roundtrip', 'stream_reasoning_and_content',
'stream_cancel', 'cache_hit_and_miss', 'context_limit', 'context_compression']
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--base-url', default='http://127.0.0.1:8000')
parser.add_argument('--provider', required=True)
parser.add_argument('--model', required=True)
parser.add_argument('--output', required=True, type=Path)
parser.add_argument('--execute', action='store_true', help='Perform one provider connection test; may incur provider charges')
args = parser.parse_args()
target = urlparse(args.base_url)
if target.scheme != 'http' or target.hostname not in ('127.0.0.1', 'localhost', '::1') or target.username or target.password or target.query or target.fragment:
parser.error('Use a local HTTP AI Core address without credentials or query parameters')
result = {'date': datetime.now(timezone.utc).isoformat(), 'provider': args.provider, 'model': args.model,
'max_test_requests': 1, 'connection': 'pending',
'scenarios': {name: 'pending' for name in SCENARIOS}, 'overall': 'not_accepted'}
if args.execute:
body = json.dumps({'provider_id': args.provider, 'model': args.model}).encode()
request = Request(args.base_url.rstrip('/') + '/api/providers/test', data=body, headers={'Content-Type': 'application/json'}, method='POST')
try:
with urlopen(request, timeout=60) as response:
payload = json.load(response)
result['connection'] = 'passed' if payload.get('success') is True else 'failed'
result['latency_ms'] = payload.get('latency_ms')
except HTTPError as error:
result['connection'] = 'failed'
result['http_status'] = error.code # Do not persist remote error bodies or headers.
except (URLError, TimeoutError, ValueError):
result['connection'] = 'unavailable'
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
if __name__ == '__main__':
main()
+16
View File
@@ -0,0 +1,16 @@
"""Score authorized reference/hypothesis JSON segment arrays without a model or network."""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.acceptance import score
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('reference', type=Path)
parser.add_argument('hypothesis', type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
result = score(json.loads(args.reference.read_text(encoding='utf-8-sig')), json.loads(args.hypothesis.read_text(encoding='utf-8-sig')))
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
+33
View File
@@ -0,0 +1,33 @@
import pytest
from app.acceptance import score
def segment(text, speaker='A', start=0, end=1):
return dict(text=text, speaker=speaker, start=start, end=end)
def test_exact_and_renamed_speakers():
result = score([segment('你好 世界')], [segment('你好 世界', 'cluster_4')])
assert result['text']['cer']['rate'] == 0
assert result['speaker']['der'] == 0
assert result['quality_gate'] == 'not_evaluated'
def test_edits_missed_and_false_alarms():
result = score([segment('a b')], [segment('a c', start=0, end=2)])
assert result['text']['wer']['rate'] == 0.5
assert result['speaker']['false_alarm_seconds'] == 1
result = score([segment('a')], [])
assert result['speaker']['der'] == 1
def test_overlap_and_confusion():
result = score([segment('a'), segment('b', 'B')], [segment('a')])
assert result['speaker']['der'] == 0.5
result = score([segment('a'), segment('b','B',1,2)], [segment('a','X',0,2)])
assert result['speaker']['confusion_seconds'] == 1
def test_requires_reference_and_valid_timing():
with pytest.raises(ValueError): score([], [])
with pytest.raises(ValueError): score([segment('a', end=float('nan'))], [])
@@ -0,0 +1,68 @@
from pathlib import Path
import pytest
from app.agent.tools import ToolRegistry
from app.extensions import SkillRuntime
from app.extensions.installed import InstalledRuntime
def package(root):
root.mkdir(parents=True)
(root / 'skill.yaml').write_text('skill_id: audit\nname: Audit\nversion: 1.0.0\npermissions: []\ntools: []\n', encoding='utf-8')
return root
def runtime(data):
return InstalledRuntime(SkillRuntime(ToolRegistry()), 'skill', data)
def test_restores_enabled_and_disabled_without_deleting_directory_install(tmp_path):
root = package(tmp_path / 'user-source')
data = tmp_path / 'data'
first = runtime(data); first.install(root); first.enable('audit')
second = runtime(data); second.restore()
assert second.get('audit').enabled
second.disable('audit')
third = runtime(data); third.restore()
assert not third.get('audit').enabled
third.uninstall('audit')
assert root.exists()
fourth = runtime(data); fourth.restore()
assert fourth.list() == []
def test_owned_zip_removed_and_changed_packages_not_auto_enabled(tmp_path):
data = tmp_path / 'data'
owned = data / 'extension-packages/skill-test'
root = package(owned / 'nested')
first = runtime(data); first.install(root, managed_root=owned); first.enable('audit')
(root / 'prompt.md').write_text('changed', encoding='utf-8')
first.disable('audit')
with pytest.raises(Exception, match='Package changed'):
first.enable('audit')
second = runtime(data); second.restore()
assert second.list() == []
assert second.restore_errors[0]['id'] == 'audit'
first.uninstall('audit')
assert not owned.exists()
def test_rejects_claiming_user_directory_as_managed(tmp_path):
root = package(tmp_path / 'source')
with pytest.raises(ValueError, match='managed'):
runtime(tmp_path / 'data').install(root, managed_root=root)
assert root.exists()
def test_builtin_disabled_plugin_does_not_break_startup():
from app.container import build_container
first = build_container()
first.plugins.disable('text-tools')
second = build_container()
assert not second.plugins.get('text-tools').enabled
assert second.skills.get('knowledge-assistant').missing_dependencies
second.plugins.enable('text-tools')
third = build_container()
assert third.plugins.get('text-tools').enabled
assert third.skills.get('knowledge-assistant').enabled
for container in (first, second, third):
container.plugins.shutdown(); container.mcp_servers.shutdown()