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.
Files changed (65) hide show
  1. copro/__init__.py +3 -0
  2. copro/__main__.py +3 -0
  3. copro/_legacy.py +112 -0
  4. copro/application/__init__.py +0 -0
  5. copro/application/conversation_store.py +95 -0
  6. copro/application/cross_check_orchestrator.py +360 -0
  7. copro/application/delegate_task_use_case.py +730 -0
  8. copro/application/dto.py +89 -0
  9. copro/application/job_manager.py +79 -0
  10. copro/application/map_reduce_orchestrator.py +126 -0
  11. copro/application/progress.py +129 -0
  12. copro/cli.py +183 -0
  13. copro/commands.py +67 -0
  14. copro/config/__init__.py +0 -0
  15. copro/config/budgets.yaml +97 -0
  16. copro/config/default.yaml +336 -0
  17. copro/config/routing_from_eval.yaml +26 -0
  18. copro/config/settings.py +394 -0
  19. copro/config/task_profiles.yaml +111 -0
  20. copro/domain/__init__.py +0 -0
  21. copro/domain/chunker.py +32 -0
  22. copro/domain/exceptions.py +50 -0
  23. copro/domain/file_context.py +356 -0
  24. copro/domain/file_selection.py +194 -0
  25. copro/domain/finding_merge.py +115 -0
  26. copro/domain/guardrails.py +132 -0
  27. copro/domain/interfaces.py +65 -0
  28. copro/domain/models.py +119 -0
  29. copro/domain/output_parser.py +64 -0
  30. copro/domain/prompt_builder.py +90 -0
  31. copro/domain/rag_indexer.py +73 -0
  32. copro/domain/token_estimate.py +38 -0
  33. copro/domain/validators.py +223 -0
  34. copro/evals/__init__.py +1 -0
  35. copro/evals/dataset.py +62 -0
  36. copro/evals/judge.py +194 -0
  37. copro/evals/routing.py +223 -0
  38. copro/evals/runner.py +492 -0
  39. copro/evals/scoring.py +151 -0
  40. copro/infra/__init__.py +0 -0
  41. copro/infra/cache.py +41 -0
  42. copro/infra/circuit_breaker.py +201 -0
  43. copro/infra/event_log.py +119 -0
  44. copro/infra/logging.py +188 -0
  45. copro/infra/quota_tracker.py +407 -0
  46. copro/infra/retry.py +56 -0
  47. copro/infra/shared_key_state.py +249 -0
  48. copro/infra/stats.py +212 -0
  49. copro/infra/url_fetcher.py +306 -0
  50. copro/infra/vector_store.py +43 -0
  51. copro/metrics_exporter.py +268 -0
  52. copro/providers/__init__.py +0 -0
  53. copro/providers/caching_provider.py +71 -0
  54. copro/providers/concurrency_provider.py +50 -0
  55. copro/providers/ollama_embedding_provider.py +57 -0
  56. copro/providers/ollama_provider.py +133 -0
  57. copro/providers/openai_compatible_provider.py +371 -0
  58. copro/providers/registry.py +204 -0
  59. copro/server.py +382 -0
  60. copro_mcp-0.4.0.dist-info/METADATA +478 -0
  61. copro_mcp-0.4.0.dist-info/RECORD +65 -0
  62. copro_mcp-0.4.0.dist-info/WHEEL +5 -0
  63. copro_mcp-0.4.0.dist-info/entry_points.txt +7 -0
  64. copro_mcp-0.4.0.dist-info/licenses/LICENSE +21 -0
  65. copro_mcp-0.4.0.dist-info/top_level.txt +1 -0
copro/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Copro - MCP middleware between Claude Code and local/cloud LLM workers."""
2
+
3
+ __version__ = "0.4.0"
copro/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from copro.commands import run
2
+
3
+ run()
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"