Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions agent-svc/agent/experimental/live_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Admission-versioned live execution bounds; older work keeps its deadline."""

CURRENT_LIVE_POLICY = "durable-live/5"


def execution_timeout_seconds(policy_version: str | None) -> int:
"""Select the immutable wall-clock allowance for an admitted policy."""
return 300 if policy_version == "durable-live/5" else 180
25 changes: 21 additions & 4 deletions agent-svc/agent/experimental/live_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ..settings import load_settings
from .durable_plan import plan_queries
from .durable_research import OutcomeUnknownError
from .live_policy import execution_timeout_seconds
from .model_review import ModelReply
from .model_transport import ReviewTransport
from .query_construction import CapturedSource
Expand All @@ -33,15 +34,23 @@ async def run_live(record: Any, app: Any):
raise ValueError("admission unavailable")
evaluated = datetime.fromisoformat(admitted.payload["evaluation_time"])
model = admitted.payload["model"]
source_bound_policy = admitted.payload.get("policy_version") == "durable-live/4"
source_bound_policy = admitted.payload.get("policy_version") in {
"durable-live/4",
"durable-live/5",
}
execution_timeout = execution_timeout_seconds(
admitted.payload.get("policy_version")
)
bounded_evidence_policy = admitted.payload.get("policy_version") in {
"durable-live/3",
"durable-live/4",
"durable-live/5",
}
planned_policy = admitted.payload.get("policy_version") in {
"durable-live/2",
"durable-live/3",
"durable-live/4",
"durable-live/5",
}
selected_sources = tuple(
CapturedSource(**source)
Expand Down Expand Up @@ -214,9 +223,9 @@ async def fetch():
api_key=app.state.llm_api_key,
token_parameter=token_parameter,
token_budget=token_budget,
# The outer admission deadline bounds all version-4 work together.
# The outer admission deadline bounds all source-bound work together.
# Do not abandon a valid slow draft at the legacy 90-second limit.
timeout=180 if source_bound_policy else 90,
timeout=execution_timeout if source_bound_policy else 90,
)

async def complete(request):
Expand Down Expand Up @@ -245,7 +254,11 @@ async def fetch():
**{**values, "content": values["content"].encode("utf-8")}
)

remaining = admitted.created_at_ms / 1000 + 180 - datetime.now(UTC).timestamp()
remaining = (
admitted.created_at_ms / 1000
+ execution_timeout
- datetime.now(UTC).timestamp()
)
if remaining <= 0:
raise ValueError("research execution deadline expired")
async with asyncio.timeout(remaining):
Expand Down Expand Up @@ -282,4 +295,8 @@ async def fetch():
max_output_tokens=completion_budget,
evidence_budget_chars=32000 if bounded_evidence_policy else None,
source_bound=source_bound_policy,
timeout_seconds=execution_timeout,
model_timeout_seconds=execution_timeout
if admitted.payload.get("policy_version") == "durable-live/5"
else None,
)
6 changes: 4 additions & 2 deletions agent-svc/agent/experimental/model_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def __init__(
) -> None:
for value, ceiling in (
(max_calls, 128),
(timeout_seconds, 120),
(timeout_seconds, 300),
(max_output_tokens, 8192),
):
if type(value) is not int or not 1 <= value <= ceiling:
Expand Down Expand Up @@ -348,7 +348,9 @@ async def _review(
payload = json.dumps(
{
"input_digest": digest,
"check_type": material.get("check", {}).get("check_type", "render_audit"),
"check_type": material.get("check", {}).get(
"check_type", "render_audit"
),
"allowed_outcomes": list(outcomes),
"response_schema": schema,
**material,
Expand Down
11 changes: 10 additions & 1 deletion agent-svc/agent/experimental/query_construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ async def construct_research(
max_output_tokens: int = 2048,
evidence_budget_chars: int | None = None,
source_bound: bool = False,
model_timeout_seconds: int | None = None,
) -> ConstructedResearch:
"""One model call, no retries; returned knowledge is NOT verified or publishable."""
if (
Expand All @@ -240,6 +241,12 @@ async def construct_research(
raise ValueError("construction source budget exceeded")
if sum(len(s.text.splitlines()) for s in sources) > 10_000:
raise ValueError("construction source line budget exceeded")
if model_timeout_seconds is not None and (
not source_bound
or type(model_timeout_seconds) is not int
or not 1 <= model_timeout_seconds <= 300
):
raise ValueError("invalid source-bound model deadline")
research_id = research_id or str(uuid4())
revision_id = revision_id or str(uuid4())
snapshots, resolved = [], []
Expand Down Expand Up @@ -315,7 +322,9 @@ async def construct_research(
else ""
)
)
async with asyncio.timeout(120):
async with asyncio.timeout(
model_timeout_seconds if model_timeout_seconds is not None else 120
):
reply = await asyncio.ensure_future(
complete(
ReviewRequest(
Expand Down
7 changes: 6 additions & 1 deletion agent-svc/agent/experimental/query_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ async def run_query(
max_output_tokens: int = 2048,
evidence_budget_chars: int | None = None,
source_bound: bool = False,
timeout_seconds: int = 180,
model_timeout_seconds: int | None = None,
) -> JourneyResult:
"""Run a bounded query plan, retained acquisitions, construction and checks.

Expand All @@ -49,6 +51,8 @@ async def run_query(
or len(objective) > 10_000
):
raise ValueError("invalid research objective")
if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 300:
raise ValueError("invalid research deadline")
ceiling = 8 if queries is not None else 3
if type(source_limit) is not int or not 1 <= source_limit <= ceiling:
raise ValueError("invalid bounded source limit")
Expand All @@ -69,7 +73,7 @@ async def run_query(
seed_bytes = sum(len(source.text.encode()) for source in selected_sources)
if seed_bytes > 256_000:
raise ValueError("retained context exceeds research byte budget")
async with asyncio.timeout(180):
async with asyncio.timeout(timeout_seconds):
fresh_limit = min(source_limit, 8 - len(selected_sources))
results = []
for query in planned if fresh_limit else ():
Expand Down Expand Up @@ -156,4 +160,5 @@ async def run_query(
max_output_tokens=max_output_tokens,
evidence_budget_chars=evidence_budget_chars,
source_bound=source_bound,
model_timeout_seconds=model_timeout_seconds,
)
6 changes: 5 additions & 1 deletion agent-svc/agent/experimental/real_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ async def research_from_sources(
max_output_tokens: int = 2048,
evidence_budget_chars: int | None = None,
source_bound: bool = False,
model_timeout_seconds: int | None = None,
) -> JourneyResult:
"""Construct, check and audit a bounded root; no retained publication yet."""
execution_clock = clock or (lambda: datetime.now(UTC))
Expand All @@ -241,12 +242,15 @@ async def research_from_sources(
max_output_tokens=max_output_tokens,
evidence_budget_chars=evidence_budget_chars,
source_bound=source_bound,
model_timeout_seconds=model_timeout_seconds,
)
adapter = ModelReviewAdapter(
provider="configured-litellm",
model=model,
complete=complete,
timeout_seconds=90,
timeout_seconds=model_timeout_seconds
if model_timeout_seconds is not None
else 90,
max_output_tokens=max_output_tokens,
)
mechanical_reviewer = ToolReviewer(
Expand Down
13 changes: 10 additions & 3 deletions agent-svc/agent/routes/experimental_research.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
OutcomeUnknownError,
)
from ..experimental.knowledge_checks import HistoricalValidityError
from ..experimental.live_policy import CURRENT_LIVE_POLICY, execution_timeout_seconds
from ..experimental.live_runner import run_live
from ..experimental.publication_gate import UnverifiedResearchError
from ..experimental.query_construction import CapturedSource
Expand Down Expand Up @@ -732,7 +733,9 @@ async def _execute_run(record: _RunRecord) -> None:
_event(record, "progress", "running", stage="acquisition")
if record.execution_mode == "live":
remaining = (
claimed.created_at_ms / 1000 + 180 - datetime.now(UTC).timestamp()
claimed.created_at_ms / 1000
+ execution_timeout_seconds(claimed.payload.get("policy_version"))
- datetime.now(UTC).timestamp()
)
async with asyncio.timeout(max(0, remaining)):
result = await run_live(record, record.app)
Expand Down Expand Up @@ -1003,7 +1006,9 @@ def capability_document() -> dict[str, Any]:
"searches": 3,
"sources": 8,
"source_bytes": 256_000,
"timeout_seconds": 180,
"timeout_seconds": execution_timeout_seconds(CURRENT_LIVE_POLICY)
if live_creation
else 180,
"model_calls": 64,
"automatic_ambiguous_call_retry": False,
"webhook": live_creation,
Expand Down Expand Up @@ -1135,7 +1140,9 @@ async def admit_research_run(
"objective": payload.objective,
"research_id": str(uuid4()),
"execution_mode": "fixture" if fixture else "live",
"policy_version": "durable-live/4",
"policy_version": "durable-live/4"
if fixture
else CURRENT_LIVE_POLICY,
"model": model,
"token_params": _token_budget_params(),
"selected_sources": [asdict(source) for source in selected_sources],
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0070-execute-live-durable-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- Deciders: GroktoCrawl maintainers
- Scope: `/experimental/research/v1` run execution; legacy jobs are unchanged

Later changes: [ADR-0073](0073-publish-source-bounded-research.md) defines source-bound answers. [ADR-0074](0074-use-a-five-minute-live-research-deadline.md) gives new version-5 admissions a five-minute deadline; earlier policies keep their original limits.

## Context

ADR-0069 adopted storage and verification components, but the HTTP run worker selected a scripted example. Separately tested real-source construction did not prove that an HTTP objective acquired sources or retained those bytes. Connecting that function without lifecycle integration would repeat external calls after crashes: the old worker renewed no lease and checkpointed only its terminal result.
Expand Down
56 changes: 56 additions & 0 deletions docs/adr/0074-use-a-five-minute-live-research-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Give new live research five minutes to finish

- Status: accepted
- Date: 2026-10-17
- Scope: newly admitted live durable research
- Extends: [ADR-0070](0070-execute-live-durable-research.md) and [ADR-0073](0073-publish-source-bounded-research.md)

## Context

The three-minute limit came from the bounded research experiment. Live checks
have since measured a model draft taking 153 seconds and model review taking
35 seconds on a separate run. Combining those observed stages with planning
and page capture would exceed three minutes. Shorter nested timers also stopped
waiting for drafts that the model gateway later completed successfully.

This is a time allowance for completing bounded work, not a performance target.
A longer wait does not justify more searches, larger model outputs, or retries.

## Decision

New live admissions pin `durable-live/5` and have one absolute 300-second deadline
measured from admission. Planning, search, capture, drafting, review and report
preparation share that allowance. Model transports and model-stage wrappers may
wait within it; they cannot each consume a separate five minutes.

The worker selects the allowance from the stored policy version, including on
recovery. Earlier admissions retain their 180-second overall limit and original
policy-specific behavior, including their original model-stage limits. Future
policy versions must preserve these mappings.

Search, source, byte, model-call and token limits do not increase. External call
intents, exact receipts, cancellation fences, ownership checks and atomic report
publication are unchanged. An unfinished external dispatch still stops as
`outcome_unknown`; it is never silently repeated. Model or source content cannot
select a longer deadline. The capabilities endpoint advertises the new limit.

## Consequences

A normal draft and review have more room to complete without a new external
call. Slow or unavailable providers can still exhaust the finite allowance.
Five minutes is a cap, not a promise that research will be complete or useful.
Measured latency and report quality remain separate observations.

There is no storage migration, new feature flag, provider change or CLI change.
Older workers do not understand version 5: rollback must wait until new-policy
admissions are terminal and active work is zero. Retain all receipts and data;
do not start an old worker to reinterpret unfinished version-5 operations.

## Verification

Test the measured stage combination against both limits: it fits within 300
seconds and exceeds 180. A run exceeding 300 must still stop. Exercise real
cancellation and refusal to replay during planning, drafting and review. Verify
old policy deadlines and exact request reuse, and check the admitted policy and
advertised allowance through the public HTTP route with isolated stores.
Production verification must retrieve real reports on each deployment.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,6 @@ An Architecture Decision Record captures an important architectural decision mad
| 0071 | [Use pgvector for Research Memory with Reversible Rollback](0071-use-pgvector-for-research-memory-with-reversible-rollback.md) | accepted |
| 0072 | [Recover Legacy Async Jobs with Valkey Leases](0072-valkey-leased-legacy-job-recovery.md) | accepted |
| 0073 | [Publish research about captured sources with an explicit scope](0073-publish-source-bounded-research.md) | accepted |
| 0074 | [Give new live research five minutes to finish](0074-use-a-five-minute-live-research-deadline.md) | accepted |

See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the full ADR workflow: when to write an ADR, how to number it, and how to get it reviewed in a PR. Experimental records are linked from the provenance archive, not included in the mainline sequence.
2 changes: 1 addition & 1 deletion docs/guides/durable-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ curl "$GROKTOCRAWL_URL/experimental/research/v1/runs" \

Persist the idempotency key before sending. Retrying the same scoped request returns the same admission; changed input with that key returns 409. Poll the returned `status_url`. A completed result points to the checked knowledge, exact retained sources and three report layers. Known capture failures can yield insufficient coverage. Invalid construction or failed verification is a failed run, not a successful research result.

The policy permits at most three model-planned queries, eight total sources, 256,000 source bytes, 64 model calls and 180 seconds from admission. Selected retained evidence consumes the same source/byte budget and retains its original provenance. For new version-4 runs, model calls may use the time remaining within that shared 180-second deadline. They do not stop at the older transport's separate 90-second limit. Older admissions retain their original limit. There are no automatic model retries. This is bounded research, not an unlimited search guarantee.
New live admissions use policy `durable-live/5`: at most three model-planned queries, eight total sources, 256,000 source bytes, 64 model calls and five minutes from admission. Selected retained evidence consumes the same source/byte budget and retains its original provenance. Planning, capture, drafting and review share that five-minute allowance. This is a finite execution limit, not a speed target or a guarantee of a complete answer. Earlier admissions retain their original overall and stage limits. There are no automatic model retries. See [ADR-0074](../adr/0074-use-a-five-minute-live-research-deadline.md) for the measured reason and rollback requirements.

For direct HTTP run creation, an optional `as_of`, such as `"2026-09-01T00:00:00Z"`, evaluates evidence at that target time. It must include a timezone and cannot be in the future. It does not select a historical search index or recover archived pages. A newly captured page does not prove what was true before its capture; a run lacking eligible evidence fails with `historical_validity_unestablished`. Unknown retrieval dates remain unknown. Historical evidence must still pass support and other verification checks.

Expand Down
12 changes: 11 additions & 1 deletion tests/integration/test_live_research_http_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,17 +1065,27 @@ async def test_current_undated_sources_explain_failure_and_survive_recovery(
async def test_source_bounded_live_report_is_explicit_about_current_validity(
durable_apps, provider_twin
):
apps, _, _, _, _, cleanup_ids, _ = durable_apps
apps, _, _, ledgers, _, cleanup_ids, _ = durable_apps
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=apps[0]), base_url="http://acceptance"
) as client:
capabilities = await client.get("/experimental/research/v1/capabilities")
assert capabilities.status_code == 200
assert (
capabilities.json()["operations"]["runs"]["policy"]["timeout_seconds"]
== 300
)
response = await client.post(
"/experimental/research/v1/runs",
headers={"Idempotency-Key": "source-bounded-answer"},
json={"objective": next(iter(OBJECTIVES))},
)
assert response.status_code == 202
admitted = response.json()
assert (
ledgers[-1].get(admitted["run_id"]).payload["policy_version"]
== "durable-live/5"
)
cleanup_ids.append(admitted["research_id"])
status = await _terminal(client, admitted["status_url"])
assert status["state"] == "completed", status
Expand Down
Loading
Loading