copro-mcp 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- copro/__init__.py +3 -0
- copro/__main__.py +3 -0
- copro/_legacy.py +112 -0
- copro/application/__init__.py +0 -0
- copro/application/conversation_store.py +95 -0
- copro/application/cross_check_orchestrator.py +360 -0
- copro/application/delegate_task_use_case.py +730 -0
- copro/application/dto.py +89 -0
- copro/application/job_manager.py +79 -0
- copro/application/map_reduce_orchestrator.py +126 -0
- copro/application/progress.py +129 -0
- copro/cli.py +183 -0
- copro/commands.py +67 -0
- copro/config/__init__.py +0 -0
- copro/config/budgets.yaml +97 -0
- copro/config/default.yaml +336 -0
- copro/config/routing_from_eval.yaml +26 -0
- copro/config/settings.py +394 -0
- copro/config/task_profiles.yaml +111 -0
- copro/domain/__init__.py +0 -0
- copro/domain/chunker.py +32 -0
- copro/domain/exceptions.py +50 -0
- copro/domain/file_context.py +356 -0
- copro/domain/file_selection.py +194 -0
- copro/domain/finding_merge.py +115 -0
- copro/domain/guardrails.py +132 -0
- copro/domain/interfaces.py +65 -0
- copro/domain/models.py +119 -0
- copro/domain/output_parser.py +64 -0
- copro/domain/prompt_builder.py +90 -0
- copro/domain/rag_indexer.py +73 -0
- copro/domain/token_estimate.py +38 -0
- copro/domain/validators.py +223 -0
- copro/evals/__init__.py +1 -0
- copro/evals/dataset.py +62 -0
- copro/evals/judge.py +194 -0
- copro/evals/routing.py +223 -0
- copro/evals/runner.py +492 -0
- copro/evals/scoring.py +151 -0
- copro/infra/__init__.py +0 -0
- copro/infra/cache.py +41 -0
- copro/infra/circuit_breaker.py +201 -0
- copro/infra/event_log.py +119 -0
- copro/infra/logging.py +188 -0
- copro/infra/quota_tracker.py +407 -0
- copro/infra/retry.py +56 -0
- copro/infra/shared_key_state.py +249 -0
- copro/infra/stats.py +212 -0
- copro/infra/url_fetcher.py +306 -0
- copro/infra/vector_store.py +43 -0
- copro/metrics_exporter.py +268 -0
- copro/providers/__init__.py +0 -0
- copro/providers/caching_provider.py +71 -0
- copro/providers/concurrency_provider.py +50 -0
- copro/providers/ollama_embedding_provider.py +57 -0
- copro/providers/ollama_provider.py +133 -0
- copro/providers/openai_compatible_provider.py +371 -0
- copro/providers/registry.py +204 -0
- copro/server.py +382 -0
- copro_mcp-0.4.0.dist-info/METADATA +478 -0
- copro_mcp-0.4.0.dist-info/RECORD +65 -0
- copro_mcp-0.4.0.dist-info/WHEEL +5 -0
- copro_mcp-0.4.0.dist-info/entry_points.txt +7 -0
- copro_mcp-0.4.0.dist-info/licenses/LICENSE +21 -0
- copro_mcp-0.4.0.dist-info/top_level.txt +1 -0
copro/__init__.py
ADDED
copro/__main__.py
ADDED
copro/_legacy.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Compatibility with the project's old name, llm-worker-pool (until 0.4.x ends).
|
|
2
|
+
|
|
3
|
+
Three things carried the old name on installs that predate 0.4.0:
|
|
4
|
+
- settings in LLM_WORKER_POOL_* environment variables (and in .env files);
|
|
5
|
+
- the per-user directory ~/.llm_worker_pool (keys .env, stats, event log, key state);
|
|
6
|
+
- the commands llm-worker-pool, worker-stats, worker-metrics and worker-eval.
|
|
7
|
+
|
|
8
|
+
All three keep working, each with a note on stderr saying what to rename. stderr, not
|
|
9
|
+
stdout: the server speaks MCP over stdout.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Callable, MutableMapping
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
LEGACY_ENV_PREFIX = "LLM_WORKER_POOL_"
|
|
21
|
+
ENV_PREFIX = "COPRO_"
|
|
22
|
+
LEGACY_USER_DIR_NAME = ".llm_worker_pool"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _warn(message: str) -> None:
|
|
26
|
+
print(f"copro: {message}", file=sys.stderr)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def copy_legacy_env(environ: MutableMapping[str, str] | None = None) -> list[str]:
|
|
30
|
+
"""Copy each LLM_WORKER_POOL_X to COPRO_X unless COPRO_X is already set, so the new
|
|
31
|
+
name always wins. Returns the old names that were copied."""
|
|
32
|
+
env = os.environ if environ is None else environ
|
|
33
|
+
copied = []
|
|
34
|
+
for name in sorted(env):
|
|
35
|
+
if not name.startswith(LEGACY_ENV_PREFIX):
|
|
36
|
+
continue
|
|
37
|
+
new_name = ENV_PREFIX + name[len(LEGACY_ENV_PREFIX):]
|
|
38
|
+
if new_name not in env:
|
|
39
|
+
env[new_name] = env[name]
|
|
40
|
+
copied.append(name)
|
|
41
|
+
if copied:
|
|
42
|
+
_warn(
|
|
43
|
+
f"read {len(copied)} setting(s) from the old LLM_WORKER_POOL_ prefix "
|
|
44
|
+
f"({', '.join(copied)}); rename them to COPRO_ (the old prefix goes after 0.4.x)"
|
|
45
|
+
)
|
|
46
|
+
return copied
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def migrate_user_dir(user_dir: Path, legacy_dir: Path | None = None) -> bool:
|
|
50
|
+
"""Copy ~/.llm_worker_pool into ~/.copro the first time, when only the old one
|
|
51
|
+
exists. Copy, not move: a server of the old version may still be running and
|
|
52
|
+
writing there. Lock files are left behind. Returns True when it copied."""
|
|
53
|
+
legacy = legacy_dir if legacy_dir is not None else user_dir.parent / LEGACY_USER_DIR_NAME
|
|
54
|
+
if user_dir.exists() or not legacy.is_dir():
|
|
55
|
+
return False
|
|
56
|
+
# Copy into a directory of our own, then rename it into place in one step: several
|
|
57
|
+
# processes may start at once (Claude Code, the metrics exporter), and none of them
|
|
58
|
+
# may ever see, or delete, a half-copied ~/.copro made by another.
|
|
59
|
+
staging = user_dir.with_name(f"{user_dir.name}.migrating-{os.getpid()}")
|
|
60
|
+
try:
|
|
61
|
+
shutil.copytree(legacy, staging, ignore=shutil.ignore_patterns("*.lock"))
|
|
62
|
+
os.rename(staging, user_dir)
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
65
|
+
if user_dir.exists():
|
|
66
|
+
return False # another process finished the copy first
|
|
67
|
+
# Never fatal: the server starts with empty stats, and keys can still come from
|
|
68
|
+
# the real environment.
|
|
69
|
+
_warn(f"could not copy {legacy} to {user_dir}: {exc}")
|
|
70
|
+
return False
|
|
71
|
+
_warn(f"copied {legacy} to {user_dir}; the old directory is no longer read")
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _alias(old: str, new: str, target: Callable[[], object]) -> Callable[[], None]:
|
|
76
|
+
def run() -> None:
|
|
77
|
+
_warn(f"`{old}` is the old name of `{new}` and goes after 0.4.x")
|
|
78
|
+
result = target()
|
|
79
|
+
if isinstance(result, int):
|
|
80
|
+
raise SystemExit(result)
|
|
81
|
+
|
|
82
|
+
return run
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _server() -> object:
|
|
86
|
+
from copro.server import main
|
|
87
|
+
|
|
88
|
+
return main()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _stats() -> object:
|
|
92
|
+
from copro.cli import main
|
|
93
|
+
|
|
94
|
+
return main()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _metrics() -> object:
|
|
98
|
+
from copro.metrics_exporter import main
|
|
99
|
+
|
|
100
|
+
return main()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _eval() -> object:
|
|
104
|
+
from copro.evals.runner import main
|
|
105
|
+
|
|
106
|
+
return main()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
llm_worker_pool = _alias("llm-worker-pool", "copro-mcp", _server)
|
|
110
|
+
worker_stats = _alias("worker-stats", "copro stats", _stats)
|
|
111
|
+
worker_metrics = _alias("worker-metrics", "copro metrics", _metrics)
|
|
112
|
+
worker_eval = _alias("worker-eval", "copro eval", _eval)
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Multi-turn follow-ups (`continuation_id`): keep what a call was given and answered,
|
|
2
|
+
so the next call can say "continue" or "now check X" without resending the files.
|
|
3
|
+
|
|
4
|
+
History is replayed as a transcript inside the user prompt rather than as chat
|
|
5
|
+
messages: every provider, wrapper (cache, concurrency, quota) and test double keeps
|
|
6
|
+
its single-prompt signature, and the transcript is redacted like any other task text.
|
|
7
|
+
|
|
8
|
+
The stored context is the text BEFORE secret redaction: each call redacts for its own
|
|
9
|
+
fallback chain, so a conversation started on a local model keeps its full text there
|
|
10
|
+
and is still redacted if a later turn goes to the cloud.
|
|
11
|
+
|
|
12
|
+
Two follow-ups sent in parallel on the same id both build on the same history and the
|
|
13
|
+
later one to finish wins (one turn is not recorded) - follow-ups are sequential in
|
|
14
|
+
practice, so this is not locked.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from copro.domain.token_estimate import estimate_tokens
|
|
24
|
+
from copro.infra.cache import InMemoryTTLCache
|
|
25
|
+
|
|
26
|
+
# Fields a follow-up inherits from the conversation unless the caller sets them.
|
|
27
|
+
INHERITED_FIELDS = ("provider", "budget", "task_type", "output_format", "json_schema")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Turn:
|
|
32
|
+
task: str
|
|
33
|
+
answer: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Conversation:
|
|
38
|
+
context: str | None
|
|
39
|
+
settings: dict[str, Any]
|
|
40
|
+
turns: tuple[Turn, ...] = field(default_factory=tuple)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConversationStore:
|
|
44
|
+
def __init__(self, max_entries: int = 50, ttl_seconds: float = 3600, max_turns: int = 20) -> None:
|
|
45
|
+
self._cache: InMemoryTTLCache[Conversation] = InMemoryTTLCache(
|
|
46
|
+
max_entries=max_entries, ttl_seconds=ttl_seconds
|
|
47
|
+
)
|
|
48
|
+
self._max_turns = max_turns
|
|
49
|
+
|
|
50
|
+
def get(self, conversation_id: str) -> Conversation | None:
|
|
51
|
+
return self._cache.get(conversation_id)
|
|
52
|
+
|
|
53
|
+
def append(
|
|
54
|
+
self,
|
|
55
|
+
conversation_id: str | None,
|
|
56
|
+
*,
|
|
57
|
+
context: str | None,
|
|
58
|
+
settings: dict[str, Any],
|
|
59
|
+
task: str,
|
|
60
|
+
answer: str,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Record one answered turn; a new id when `conversation_id` is None. The
|
|
63
|
+
TTL restarts on every turn."""
|
|
64
|
+
previous = self._cache.get(conversation_id) if conversation_id else None
|
|
65
|
+
turns = (*(previous.turns if previous else ()), Turn(task=task, answer=answer))
|
|
66
|
+
conversation_id = conversation_id or uuid.uuid4().hex
|
|
67
|
+
self._cache.set(
|
|
68
|
+
conversation_id,
|
|
69
|
+
Conversation(context=context, settings=settings, turns=turns[-self._max_turns :]),
|
|
70
|
+
)
|
|
71
|
+
return conversation_id
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_history(turns: tuple[Turn, ...], task: str, max_tokens: int) -> tuple[str, int]:
|
|
75
|
+
"""The new task preceded by as many of the latest turns as fit in `max_tokens`.
|
|
76
|
+
Returns (prompt task, number of oldest turns left out)."""
|
|
77
|
+
blocks: list[str] = []
|
|
78
|
+
used = estimate_tokens(task)
|
|
79
|
+
for number, turn in reversed(list(enumerate(turns, start=1))):
|
|
80
|
+
block = f"[Turn {number}] Request:\n{turn.task}\n\n[Turn {number}] Your answer:\n{turn.answer}"
|
|
81
|
+
cost = estimate_tokens(block)
|
|
82
|
+
if used + cost > max_tokens:
|
|
83
|
+
break
|
|
84
|
+
blocks.append(block)
|
|
85
|
+
used += cost
|
|
86
|
+
if not blocks:
|
|
87
|
+
return task, len(turns)
|
|
88
|
+
history = "\n\n".join(reversed(blocks))
|
|
89
|
+
prompt = (
|
|
90
|
+
"This continues an earlier conversation. Earlier turns, oldest first:\n\n"
|
|
91
|
+
f"{history}\n\n"
|
|
92
|
+
"New request (if it says to continue, pick up exactly where your last answer "
|
|
93
|
+
f"stopped, without repeating it):\n{task}"
|
|
94
|
+
)
|
|
95
|
+
return prompt, len(turns) - len(blocks)
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Cross-check: run one review on several providers in parallel and merge the findings.
|
|
2
|
+
|
|
3
|
+
Measured on copro eval (2026-10-30, harness-compare): with the review contract,
|
|
4
|
+
groq found 0.94 of the seeded bugs but cited the wrong line more often (citation
|
|
5
|
+
validity 0.93 -> 0.81), while gemini-lite cited precisely but found only 0.38. The
|
|
6
|
+
providers miss different things, so their union - with each finding marked by who
|
|
7
|
+
reported it and whether its citation checks out - beats either one alone.
|
|
8
|
+
|
|
9
|
+
Each arm is pinned to its provider (no fallback chain): an arm that silently fell back
|
|
10
|
+
to the other arm's model would count one opinion twice. An arm whose provider caps the
|
|
11
|
+
prompt size (groq: max_prompt_tokens) gets the files in batches that fit, instead of
|
|
12
|
+
being skipped - small files are where it does best.
|
|
13
|
+
|
|
14
|
+
Arms are awaited, never cancelled: a cancelled call is recorded as a hang by the
|
|
15
|
+
quota tracker (infra/quota_tracker.py) and could block a healthy model.
|
|
16
|
+
|
|
17
|
+
A review that runs on ONE provider (cross_check=false, fewer than two arms up, or a
|
|
18
|
+
provider named in the request) is batched the same way by `execute_single`: on
|
|
19
|
+
review-large-l3 (~15k tokens, 2026-11-01) groq skipped the whole prompt and the
|
|
20
|
+
fallback to gemini-lite found 0.33-0.50 of the bugs, while groq on batches of whole
|
|
21
|
+
files is what made the cross-check reach 1.00. Each batch keeps its fallback chain,
|
|
22
|
+
and the findings are concatenated - no LLM reduce step, which would lose the
|
|
23
|
+
file/line/quote evidence (map_reduce does that for free-form tasks).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from copro.application import progress
|
|
33
|
+
from copro.application.delegate_task_use_case import DelegateTaskUseCase
|
|
34
|
+
from copro.application.dto import DelegateTaskRequest, DelegateTaskResult
|
|
35
|
+
from copro.domain.file_context import require_file_content
|
|
36
|
+
from copro.domain.finding_merge import findings_of, merge_findings
|
|
37
|
+
from copro.domain.token_estimate import estimate_prompt_tokens
|
|
38
|
+
from copro.domain.validators import citation_warnings, split_context_files
|
|
39
|
+
|
|
40
|
+
# System prompt (budget + grounding rules + profile addon + JSON schema) on top of
|
|
41
|
+
# task + context; measured ~500-700 tokens for review, rounded up.
|
|
42
|
+
_PROMPT_OVERHEAD_TOKENS = 1000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CrossCheckOrchestrator:
|
|
46
|
+
def __init__(self, delegate_task_use_case: DelegateTaskUseCase) -> None:
|
|
47
|
+
self._use_case = delegate_task_use_case
|
|
48
|
+
|
|
49
|
+
def wants_cross_check(self, request: DelegateTaskRequest) -> bool:
|
|
50
|
+
if request.cross_check is False:
|
|
51
|
+
return False
|
|
52
|
+
_, profile = self._use_case.apply_task_profile(request)
|
|
53
|
+
return bool(request.cross_check or (profile and profile.cross_check))
|
|
54
|
+
|
|
55
|
+
def arms(self, request: DelegateTaskRequest) -> list[str]:
|
|
56
|
+
_, profile = self._use_case.apply_task_profile(request)
|
|
57
|
+
wanted = profile.cross_check_providers if profile else []
|
|
58
|
+
available = set(self._use_case.available_providers())
|
|
59
|
+
return [name for name in dict.fromkeys(wanted) if name in available]
|
|
60
|
+
|
|
61
|
+
async def execute(self, request: DelegateTaskRequest) -> DelegateTaskResult:
|
|
62
|
+
request, profile = self._use_case.apply_task_profile(request)
|
|
63
|
+
arms = self.arms(request)
|
|
64
|
+
if request.output_format != "json" or len(arms) < 2:
|
|
65
|
+
reason = "needs_json_findings" if request.output_format != "json" else "fewer_than_two_providers"
|
|
66
|
+
result = await self.execute_single(request)
|
|
67
|
+
result.warnings.append(f"cross_check_skipped:{reason}")
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
context, context_warnings = await self._use_case.resolve_context(request)
|
|
71
|
+
require_file_content(request.file_paths, context, context_warnings)
|
|
72
|
+
base = request.model_copy(
|
|
73
|
+
update={"context": context, "file_paths": None, "urls": None, "provider": None}
|
|
74
|
+
)
|
|
75
|
+
splittable = bool(request.file_paths) and not request.context and not request.urls
|
|
76
|
+
files = split_context_files(context) if splittable else {}
|
|
77
|
+
|
|
78
|
+
batches = {arm: self._batches(arm, base, files) for arm in arms}
|
|
79
|
+
await progress.begin(
|
|
80
|
+
sum(len(b) for b in batches.values()),
|
|
81
|
+
"cross-check on " + ", ".join(f"{arm} ({len(b)} calls)" for arm, b in batches.items()),
|
|
82
|
+
)
|
|
83
|
+
arm_jobs = [self._run_arm(arm, base, batches[arm]) for arm in arms]
|
|
84
|
+
outcomes = await asyncio.gather(*arm_jobs)
|
|
85
|
+
return self._combine(request, arms, outcomes, context, context_warnings)
|
|
86
|
+
|
|
87
|
+
# --- one provider, batched ---------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def wants_batching(self, request: DelegateTaskRequest) -> bool:
|
|
90
|
+
"""The request (after its task profile) asks for a JSON `findings` list, the
|
|
91
|
+
contract whose answers can be concatenated batch by batch."""
|
|
92
|
+
request, _ = self._use_case.apply_task_profile(request)
|
|
93
|
+
return request.output_format == "json" and _asks_for_findings(request.json_schema)
|
|
94
|
+
|
|
95
|
+
async def execute_single(self, request: DelegateTaskRequest) -> DelegateTaskResult:
|
|
96
|
+
"""A findings review on one provider (the requested one, else the routed one).
|
|
97
|
+
Context over that provider's prompt cap that is made of several files goes out
|
|
98
|
+
as batches of whole files that fit; anything else is a normal call."""
|
|
99
|
+
request, _ = self._use_case.apply_task_profile(request)
|
|
100
|
+
provider = self._use_case.routed_provider(request)
|
|
101
|
+
splittable = bool(request.file_paths) and not request.context and not request.urls
|
|
102
|
+
if (
|
|
103
|
+
not splittable
|
|
104
|
+
or self._use_case.prompt_limit(provider) is None
|
|
105
|
+
or not _asks_for_findings(request.json_schema)
|
|
106
|
+
):
|
|
107
|
+
return await self._use_case.execute(request)
|
|
108
|
+
|
|
109
|
+
context, context_warnings = await self._use_case.resolve_context(request)
|
|
110
|
+
require_file_content(request.file_paths, context, context_warnings)
|
|
111
|
+
# provider stays as requested (None = routed): each batch keeps its fallbacks.
|
|
112
|
+
base = request.model_copy(update={"context": context, "file_paths": None, "urls": None})
|
|
113
|
+
files = split_context_files(context)
|
|
114
|
+
batches = self._batches(provider, base, files)
|
|
115
|
+
if len(batches) < 2:
|
|
116
|
+
# Fits, or one file too big to split: the call it would have been anyway,
|
|
117
|
+
# minus a second read of the files.
|
|
118
|
+
result = await self._use_case.execute(base)
|
|
119
|
+
return self._with_context_warnings(result, context_warnings)
|
|
120
|
+
|
|
121
|
+
await progress.begin(len(batches), f"review in {len(batches)} batches on {provider}")
|
|
122
|
+
label = progress.counter("review batch {done}/{total} done", len(batches))
|
|
123
|
+
results = await asyncio.gather(
|
|
124
|
+
*(
|
|
125
|
+
progress.step(
|
|
126
|
+
self._use_case.execute(base.model_copy(update={"context": ctx}), keep_conversation=False),
|
|
127
|
+
label,
|
|
128
|
+
)
|
|
129
|
+
for ctx in batches
|
|
130
|
+
),
|
|
131
|
+
return_exceptions=True,
|
|
132
|
+
)
|
|
133
|
+
by_provider: dict[str, list[dict[str, Any]]] = {}
|
|
134
|
+
answered: list[DelegateTaskResult] = []
|
|
135
|
+
errors: list[str] = []
|
|
136
|
+
for i, result in enumerate(results, start=1):
|
|
137
|
+
if isinstance(result, BaseException):
|
|
138
|
+
errors.append(f"review_batch_failed:{i}/{len(batches)}:{type(result).__name__}: {result}"[:300])
|
|
139
|
+
continue
|
|
140
|
+
batch_findings = findings_of(result.content) if result.success else None
|
|
141
|
+
if batch_findings is None:
|
|
142
|
+
reason = result.error or "answer has no findings list"
|
|
143
|
+
errors.append(f"review_batch_failed:{i}/{len(batches)}:{reason}"[:300])
|
|
144
|
+
continue
|
|
145
|
+
answered.append(result)
|
|
146
|
+
# Grouped by who answered: a batch that fell back is another model's opinion.
|
|
147
|
+
by_provider.setdefault(result.provider or provider, []).extend(batch_findings)
|
|
148
|
+
|
|
149
|
+
if not answered:
|
|
150
|
+
return DelegateTaskResult(
|
|
151
|
+
success=False,
|
|
152
|
+
budget_used=request.budget,
|
|
153
|
+
task_type=request.task_type,
|
|
154
|
+
error=f"Every one of {len(batches)} review batches failed: " + "; ".join(errors),
|
|
155
|
+
warnings=list(context_warnings),
|
|
156
|
+
)
|
|
157
|
+
warnings = [*context_warnings, f"review_batched:{len(batches)}", *errors]
|
|
158
|
+
return self._merged_result(request, by_provider, answered, context, warnings, cross_check=None)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _with_context_warnings(result: DelegateTaskResult, context_warnings: list[str]) -> DelegateTaskResult:
|
|
162
|
+
"""Warnings (and denied-file guardrail actions) of a context the use case did
|
|
163
|
+
not resolve itself, since it was handed the text."""
|
|
164
|
+
result.warnings = [*context_warnings, *(w for w in result.warnings if w not in context_warnings)]
|
|
165
|
+
denied = [w for w in context_warnings if w.startswith("file_denied_sensitive:")]
|
|
166
|
+
result.guardrail_actions = [*denied, *(a for a in result.guardrail_actions if a not in denied)]
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
# --- one arm ---------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async def _run_arm(
|
|
172
|
+
self, arm: str, base: DelegateTaskRequest, batches: list[str | None]
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
requests = [
|
|
175
|
+
base.model_copy(update={"provider": arm, "context": ctx}) for ctx in batches
|
|
176
|
+
]
|
|
177
|
+
label = progress.counter(f"{arm}: {{done}}/{{total}} calls done", len(batches))
|
|
178
|
+
results = await asyncio.gather(
|
|
179
|
+
*(
|
|
180
|
+
progress.step(
|
|
181
|
+
self._use_case.execute(r, allow_fallback=False, keep_conversation=False),
|
|
182
|
+
label,
|
|
183
|
+
)
|
|
184
|
+
for r in requests
|
|
185
|
+
),
|
|
186
|
+
return_exceptions=True,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
findings: list[dict[str, Any]] = []
|
|
190
|
+
errors: list[str] = []
|
|
191
|
+
answered: list[DelegateTaskResult] = []
|
|
192
|
+
for result in results:
|
|
193
|
+
if isinstance(result, BaseException):
|
|
194
|
+
errors.append(f"{type(result).__name__}: {result}")
|
|
195
|
+
continue
|
|
196
|
+
if not result.success:
|
|
197
|
+
errors.append(result.error or "failed")
|
|
198
|
+
continue
|
|
199
|
+
batch_findings = findings_of(result.content)
|
|
200
|
+
if batch_findings is None:
|
|
201
|
+
errors.append("answer has no findings list")
|
|
202
|
+
continue
|
|
203
|
+
answered.append(result)
|
|
204
|
+
findings.extend(batch_findings)
|
|
205
|
+
return {
|
|
206
|
+
"arm": arm,
|
|
207
|
+
"batches": len(batches),
|
|
208
|
+
"results": answered,
|
|
209
|
+
"findings": findings,
|
|
210
|
+
"errors": errors,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def _batches(
|
|
214
|
+
self, arm: str, base: DelegateTaskRequest, files: dict[str, str]
|
|
215
|
+
) -> list[str | None]:
|
|
216
|
+
"""The whole context, or - when it is too big for this arm and is made of
|
|
217
|
+
files - groups of whole files that each fit. A single file over the limit is
|
|
218
|
+
sent alone and left to the provider to refuse."""
|
|
219
|
+
limit = self._use_case.prompt_limit(arm)
|
|
220
|
+
overhead = estimate_prompt_tokens(base.task) + _PROMPT_OVERHEAD_TOKENS
|
|
221
|
+
if limit is None or not base.context:
|
|
222
|
+
return [base.context]
|
|
223
|
+
if overhead + estimate_prompt_tokens(base.context) <= limit or len(files) < 2:
|
|
224
|
+
return [base.context]
|
|
225
|
+
|
|
226
|
+
batches: list[str] = []
|
|
227
|
+
current: list[str] = []
|
|
228
|
+
used = overhead
|
|
229
|
+
for path, body in files.items():
|
|
230
|
+
block = f"--- file: {path} ---\n{body}"
|
|
231
|
+
cost = estimate_prompt_tokens(block)
|
|
232
|
+
if current and used + cost > limit:
|
|
233
|
+
batches.append("\n\n".join(current))
|
|
234
|
+
current, used = [], overhead
|
|
235
|
+
current.append(block)
|
|
236
|
+
used += cost
|
|
237
|
+
if current:
|
|
238
|
+
batches.append("\n\n".join(current))
|
|
239
|
+
return batches
|
|
240
|
+
|
|
241
|
+
# --- merge -----------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _combine(
|
|
244
|
+
self,
|
|
245
|
+
request: DelegateTaskRequest,
|
|
246
|
+
arms: list[str],
|
|
247
|
+
outcomes: list[dict[str, Any]],
|
|
248
|
+
context: str | None,
|
|
249
|
+
context_warnings: list[str],
|
|
250
|
+
) -> DelegateTaskResult:
|
|
251
|
+
answered = [o for o in outcomes if o["results"]]
|
|
252
|
+
arm_summary = {
|
|
253
|
+
o["arm"]: {
|
|
254
|
+
"success": bool(o["results"]),
|
|
255
|
+
"findings": len(o["findings"]),
|
|
256
|
+
"batches": o["batches"],
|
|
257
|
+
"errors": o["errors"],
|
|
258
|
+
}
|
|
259
|
+
for o in outcomes
|
|
260
|
+
}
|
|
261
|
+
if not answered:
|
|
262
|
+
return DelegateTaskResult(
|
|
263
|
+
success=False,
|
|
264
|
+
budget_used=request.budget,
|
|
265
|
+
task_type=request.task_type,
|
|
266
|
+
error="Cross-check: every provider failed: " + "; ".join(
|
|
267
|
+
f"{o['arm']}: {' | '.join(o['errors']) or 'no answer'}" for o in outcomes
|
|
268
|
+
),
|
|
269
|
+
warnings=list(context_warnings),
|
|
270
|
+
cross_check={"arms": arm_summary},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
warnings = list(context_warnings)
|
|
274
|
+
for o in outcomes:
|
|
275
|
+
if not o["results"]:
|
|
276
|
+
warnings.append(f"cross_check_partial:{o['arm']} failed: {' | '.join(o['errors'])[:300]}")
|
|
277
|
+
elif o["errors"]:
|
|
278
|
+
warnings.append(f"cross_check_partial:{o['arm']} {len(o['errors'])} of {o['batches']} batches failed")
|
|
279
|
+
return self._merged_result(
|
|
280
|
+
request,
|
|
281
|
+
{o["arm"]: o["findings"] for o in answered},
|
|
282
|
+
[r for o in answered for r in o["results"]],
|
|
283
|
+
context,
|
|
284
|
+
warnings,
|
|
285
|
+
cross_check={"arms": arm_summary},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _merged_result(
|
|
289
|
+
self,
|
|
290
|
+
request: DelegateTaskRequest,
|
|
291
|
+
by_provider: dict[str, list[dict[str, Any]]],
|
|
292
|
+
results: list[DelegateTaskResult],
|
|
293
|
+
context: str | None,
|
|
294
|
+
warnings: list[str],
|
|
295
|
+
*,
|
|
296
|
+
cross_check: dict[str, Any] | None,
|
|
297
|
+
) -> DelegateTaskResult:
|
|
298
|
+
"""One answer out of several calls' findings: merged, re-verified against the
|
|
299
|
+
whole context, usage summed, remembered as a single conversation turn."""
|
|
300
|
+
merged = merge_findings(by_provider, files=split_context_files(context))
|
|
301
|
+
content = {"findings": merged}
|
|
302
|
+
raw_text = json.dumps(content, ensure_ascii=False, indent=2)
|
|
303
|
+
|
|
304
|
+
warnings = list(warnings)
|
|
305
|
+
guardrail_actions = [w for w in warnings if w.startswith("file_denied_sensitive:")]
|
|
306
|
+
for result in results:
|
|
307
|
+
for warning in result.warnings:
|
|
308
|
+
if warning not in warnings:
|
|
309
|
+
warnings.append(warning)
|
|
310
|
+
for action in result.guardrail_actions:
|
|
311
|
+
if action not in guardrail_actions:
|
|
312
|
+
guardrail_actions.append(action)
|
|
313
|
+
|
|
314
|
+
# Re-check citations on the merged list against the full context: per-arm
|
|
315
|
+
# verification saw only that arm's answer (and, when batched, one batch).
|
|
316
|
+
warnings = [w for w in warnings if not w.startswith("citation_unverified:")]
|
|
317
|
+
verification = self._use_case.verify_citations(raw_text, content, context)
|
|
318
|
+
warnings.extend(citation_warnings(verification))
|
|
319
|
+
|
|
320
|
+
if cross_check is not None:
|
|
321
|
+
cross_check = {
|
|
322
|
+
**cross_check,
|
|
323
|
+
"findings_total": len(merged),
|
|
324
|
+
"findings_agreed": sum(1 for f in merged if len(f["agreed_by"]) > 1),
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
def total(field: str) -> int | None:
|
|
328
|
+
values = [getattr(r, field) for r in results if getattr(r, field) is not None]
|
|
329
|
+
return sum(values) if values else None
|
|
330
|
+
|
|
331
|
+
return DelegateTaskResult(
|
|
332
|
+
success=True,
|
|
333
|
+
content=content,
|
|
334
|
+
raw_text=raw_text,
|
|
335
|
+
provider="+".join(by_provider),
|
|
336
|
+
model="+".join(dict.fromkeys(r.model for r in results)),
|
|
337
|
+
budget_used=request.budget,
|
|
338
|
+
latency_ms=max(r.latency_ms for r in results),
|
|
339
|
+
tokens_used=total("tokens_used"),
|
|
340
|
+
prompt_tokens=total("prompt_tokens"),
|
|
341
|
+
completion_tokens=total("completion_tokens"),
|
|
342
|
+
thinking_tokens=total("thinking_tokens"),
|
|
343
|
+
truncated=any(r.truncated for r in results),
|
|
344
|
+
cached=all(r.cached for r in results),
|
|
345
|
+
warnings=warnings,
|
|
346
|
+
task_type=request.task_type,
|
|
347
|
+
guardrail_actions=guardrail_actions,
|
|
348
|
+
verification=verification,
|
|
349
|
+
cross_check=cross_check,
|
|
350
|
+
# A follow-up continues from the merged findings, on a single provider.
|
|
351
|
+
continuation_id=self._use_case.remember_turn(request, context, raw_text),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _asks_for_findings(schema: dict[str, Any] | None) -> bool:
|
|
356
|
+
"""The JSON schema's top level has a `findings` array (the review contract)."""
|
|
357
|
+
if not isinstance(schema, dict):
|
|
358
|
+
return False
|
|
359
|
+
findings = (schema.get("properties") or {}).get("findings")
|
|
360
|
+
return isinstance(findings, dict) and findings.get("type") == "array"
|