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
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ outside marketing, or were written without ever being run. Full rules:
- Schedules use Temporal's existing dispatcher, skip overlap, and bounded catch-up.
Calendar support is declared by the selected workflow, not inferred from its name.

## Failing well

- Refuse only to protect security, money or outside effects: credentials, grants, egress,
billing and anything published, sent or merged. Everything else degrades to a recorded
outcome with a named reason.
- A check on model output never fails a run that already paid for it. Map an unsupported
verdict to the nearest honest outcome (for example insufficient evidence), keep what is
usable, or ask the model to fix it within the run. Notes and summaries for the founder are
shaped to fit, never grounds to fail.
- Accept partial provider data: a page with one missing reference, a response with a
warning. Stay strict for writes and for responses with no usable data.
- Limits are runaway guards set well above normal use, not estimates that stop ordinary runs.
- A failure names its cause. Never wrap Tin's own refusal in a generic message; keep the
reason on the run and its receipt, bounded and without provider text or secrets.
- Fixture tests prove the contract; a real run proves the workflow. Before calling a workflow
change done, run it once for real where a provider, sandbox or outside site is involved.

## Models, sandboxes and connections

- New Codex compute is API-only. The provider credential stays on the switchboard;
Expand Down
16 changes: 11 additions & 5 deletions sandbox/task_app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,11 +417,7 @@ def execute() -> int:
"question": None,
}
else:
result = json.loads(last_agent_message)
if result.get("outcome") not in {"completed", "needs_input"}:
raise RuntimeError("Codex returned an invalid task outcome")
if result["outcome"] == "needs_input" and not result.get("question"):
raise RuntimeError("Codex requested input without a question")
result = _task_result(json.loads(last_agent_message))
with open(os.environ["TIN_TASK_RESULT_PATH"], "w", encoding="utf-8") as handle:
json.dump(result, handle, separators=(",", ":"))
return 0
Expand All @@ -434,6 +430,16 @@ def execute() -> int:
proc.kill()


def _task_result(result):
"""Codex's final task message as Tin's result; a question-less pause asks a plain one."""
if not isinstance(result, dict) or result.get("outcome") not in {"completed", "needs_input"}:
raise RuntimeError("Codex returned an invalid task outcome")
if result["outcome"] == "needs_input" and not (result.get("question") or "").strip():
# Ask rather than lose the turn: the founder can still answer or stop.
result["question"] = "How should I continue?"
return result


def main() -> int:
if not ISOLATED:
return execute()
Expand Down
2 changes: 0 additions & 2 deletions src/tin_lite/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4137,8 +4137,6 @@ async def _procedure_companions(self, run, project, procedure, revision):
)
else:
content_draft.validate_notes(raw, procedure.content_draft_context)
if procedure.review_revision_context is not None:
article_review.validate_changes(raw)
return (
OutputCheckpoint.create(
run=run,
Expand Down
12 changes: 0 additions & 12 deletions src/tin_lite/article_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,6 @@ def validate_notes(content, *, revision=False):
text = content.decode("utf-8")
if not 1 <= len(content) <= 24_000 or not text.lstrip().startswith("# Generation notes\n"):
raise ValueError("Write separate, bounded Generation notes.")
if revision:
validate_changes(content)


def validate_changes(content):
text = content.decode("utf-8")
for title in ("What changed", "Feedback not followed"):
sections = re.findall(rf"(?m)^## {title}\n+(.+?)(?=\n## |\Z)", text, re.S)
if len(sections) != 1 or not 4 <= len(sections[0].strip()) <= 2000:
raise ValueError(
f"Explain '{title}' once, in at most 2,000 characters in Generation notes."
)


def change_summary(content):
Expand Down
20 changes: 17 additions & 3 deletions src/tin_lite/content_editorial_judgment.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,16 @@ def object_pairs(pairs):
if len(set(identities)) != len(identities):
raise ValueError("List each compared page once, not duplicate anchors.")
inspected = [page for page in judgment.compared_pages if page.status == "inspected"]
host = context.get("host", "").removeprefix("www.").lower()
if judgment.outcome == "already_covered" and not _supports_covered(inspected, host, context):
# A page that couldn't be read is no evidence either way. "Already covered" needs the
# site's own coverage read (the destination, for an update); without it the honest
# result is that coverage couldn't be established, not a failed run.
judgment = judgment.model_copy(update={"outcome": "insufficient_evidence"})
if judgment.outcome in {"draft", "already_covered"} and not inspected:
raise ValueError(
"Inspect current coverage before declaring a gap or an already-covered topic."
)
host = context.get("host", "").removeprefix("www.").lower()
if (
host
and judgment.outcome in {"draft", "already_covered"}
Expand All @@ -85,8 +90,6 @@ def object_pairs(pairs):
raise ValueError(
"Inspect the planned site's own coverage before deciding to draft or skip."
)
if judgment.outcome == "already_covered" and len(inspected) != len(judgment.compared_pages):
raise ValueError("Unavailable coverage is not evidence that the brief is already covered.")
if judgment.outcome == "draft":
if min(len(judgment.reader_gain.strip()), len(judgment.change_scope.strip())) < 30:
raise ValueError("Explain the concrete reader benefit and bounded change scope.")
Expand All @@ -101,6 +104,17 @@ def object_pairs(pairs):
return {"schema": SCHEMA, **judgment.model_dump()}


def _supports_covered(inspected, host, context):
"""Whether the read pages can show the brief is already covered on the planned site."""
read = [page_identity(page.url) for page in inspected]
if host and not any(identity[0] == host for identity in read):
return False
item = context.get("item") or {}
if item.get("action") == "update_page" and item.get("destination"):
return page_identity(item["destination"]) in read
return bool(read)


def assessment_document(judgment):
return f"# Content assessment\n\n{judgment['rationale']}\n".encode()

Expand Down
11 changes: 11 additions & 0 deletions tests/test_codex_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ def test_a_no_change_outcome_needs_no_pull_request_title():
assert bridge._pull_request_text(result, {}) == ("", "")


def test_a_task_that_pauses_without_a_question_asks_a_plain_one():
# Two tasks failed on 2026-10-02 with "Codex requested input without a question".
bridge = load_sandbox_module("task_app_server")
paused = {"outcome": "needs_input", "summary": "s", "message": "m", "question": ""}
assert bridge._task_result(paused)["question"] == "How should I continue?"
asked = {**paused, "question": "Which repository?"}
assert bridge._task_result(asked)["question"] == "Which repository?"
with pytest.raises(RuntimeError, match="invalid task outcome"):
bridge._task_result({"outcome": "done"})


def test_controller_bypasses_proxy_only_for_local_worker_and_existing_hosts():
bridge = load_sandbox_module("procedure_app_server")
assert bridge._loopback_proxy_bypass({"NO_PROXY": "broker.test,127.0.0.1"}) == {
Expand Down
35 changes: 35 additions & 0 deletions tests/test_content_editorial_judgment.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,41 @@ def test_invalid_judgments_cannot_publish(bad):
validate_pair(ARTICLE, raw, context)


def update_context():
context = {**sample_context(), "output_validator": content_draft.EDITORIAL_VALIDATOR}
context["item"] = {
**context["item"],
"action": "update_page",
"destination": "https://example.com/docs",
}
return context


def test_an_unreadable_extra_page_does_not_sink_an_already_covered_judgment():
# Sheepdogs: the refresh's own page was read in full; the canvas homepage could not be.
context = update_context()
value = judgment(context)
value["compared_pages"].append(
{
"url": "https://example.com/",
"status": "unavailable",
"coverage": "The homepage returned no readable text to compare.",
}
)
proof = validate_pair(assessment_document(value), judgment_notes(context, value), context)
assert proof["outcome"] == "already_covered"


def test_covered_without_reading_the_destination_finishes_as_insufficient_evidence():
# Claiming coverage from a page that couldn't be read is not evidence; the paid run
# still finishes, with the honest outcome instead of a crash.
context = update_context()
value = judgment(context)
value["compared_pages"][0]["status"] = "unavailable"
proof = validate_pair(assessment_document(value), judgment_notes(context, value), context)
assert proof["outcome"] == "insufficient_evidence"


async def prepared(f, outcome, *, publish=True):
run = await start(f, key=str(uuid4()))
activities = TinActivities(
Expand Down
19 changes: 12 additions & 7 deletions tests/test_workflow_reviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from tin_lite import content_draft
from tin_lite.activities import TinActivities
from tin_lite.article_review import validate_article, validate_changes
from tin_lite.article_review import change_summary, validate_article, validate_notes
from tin_lite.catalog import BUILTIN_WORKFLOWS
from tin_lite.content_draft_sources import ContentDraftSources
from tin_lite.mcp_server import _review_summary
Expand Down Expand Up @@ -370,12 +370,17 @@ def test_copy_and_notes_are_different_contracts():
validate_article(article)
with pytest.raises(ValueError, match="process notes"):
validate_article(article + b"\n## What changed\nWe revised this.\n")
validate_changes(
b"# Generation notes\n\n## What changed\nShorter opening.\n"
b"\n## Feedback not followed\nNone.\n"
)
with pytest.raises(ValueError, match="Feedback not followed"):
validate_changes(b"# Generation notes\n\n## What changed\nShorter opening.\n")
# Revision notes are for the founder: a missing, repeated or long section never fails the
# paid revision; the review shows what it can.
for notes in (
b"# Generation notes\n\n## What changed\nShorter opening.\n",
b"# Generation notes\n\nRevised as asked.\n",
b"# Generation notes\n\n## Feedback not followed\n" + b"x" * 3000 + b"\n",
):
validate_notes(notes, revision=True)
assert change_summary(b"# Generation notes\n\nRevised as asked.\n") is None
with pytest.raises(ValueError, match="bounded Generation notes"):
validate_notes(b"Not notes at all", revision=True)


async def test_failed_revision_retry_retains_copy_and_can_stop_chain(publication_db, monkeypatch):
Expand Down
Loading