diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 879cd00..26c9f22 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -389,6 +389,22 @@ assert "You are a lazy senior developer" in w.venice_system and "Never cut: vali w_off = run(World([card(1, D)], {1: [beat("would-dispatch")]}), {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk", "TIME_PONYTAIL": "off"}) assert "lazy senior developer" not in w_off.venice_system and w_off.opened, "off: the same build, without the rules" os.environ.pop("TIME_PONYTAIL", None) +# 14d a call cut off at the output limit (write_file with a path and no content) or with malformed arguments is an +# error the worker reads on its next step, not a crash that throws the run away +class CutWorld(World): + cut, cut_seen = True, None + def venice(self, body): + if self.cut: + self.cut = False + return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [ + {"id": "cut1", "type": "function", "function": {"name": "write_file", "arguments": json.dumps({"path": "app.py"})}}, + {"id": "cut2", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]}}]} + self.cut_seen = [m["content"] for m in body["messages"] if m.get("role") == "tool"][:2] + return super().venice(body) +w_cut = run(CutWorld([card(1, D)], {1: [beat("would-dispatch")]}), {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk"}) +assert w_cut.opened and w_cut.files["app.py"] == "x = 2\n", "the run goes on and finishes" +assert "arrived without `content`" in w_cut.cut_seen[0] and "cut off at the output limit" in w_cut.cut_seen[0], w_cut.cut_seen +assert w_cut.cut_seen[1].startswith("error: read_file could not run (KeyError"), w_cut.cut_seen assert "on branch `time/card-1-gh-pages`" in w.posted[0][1], w.posted[0] assert tm.card_base({"body": "Base: `release/1.x`\nDONE: x"}) == "release/1.x" and tm.card_base({"body": "rebase: no\nDONE: x"}) is None assert tm.card_branch({"number": 5, "body": D}) == "time/card-5" and w.bases.get("time/card-1") is None diff --git a/.github/time/time.py b/.github/time/time.py index 0431b78..2250b6d 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -1036,6 +1036,13 @@ class Hands: return f"error: {e}" return base64.b64decode(f["content"]).decode(errors="replace")[:60000] if name == "write_file": + if not isinstance(a.get("path"), str) or not isinstance(a.get("content"), str): + # a call cut off at the output limit arrives with its path and no content (the first Claude venture + # builds died on it): say so, so the next step writes a smaller file instead of the run ending + missing = "`content`" if isinstance(a.get("path"), str) else "`path`" + return (f"error: write_file needs both `path` and `content`, and this call arrived without {missing}: most " + "likely it was cut off at the output limit. Write a shorter file, or split it (index.html, style.css, " + "app.js) and write each in its own call.") if a["path"].startswith((".github/workflows", ".gitea/workflows")): return "refused: workflow files are a human's to change" git_commit(self.branch, {a["path"]: a["content"].encode()}, a.get("message") or f"card #{self.n}: update {a['path']}") @@ -1170,7 +1177,10 @@ def venice_work(card: dict, done: str, spec: str, hands_cls=None, review: str | args = json.loads(c["function"].get("arguments") or "{}") except ValueError: args = {} - out = hands.call(c["function"]["name"], args) + try: + out = hands.call(c["function"]["name"], args) + except (KeyError, TypeError, ValueError, AttributeError) as e: # malformed arguments: the model fixes them + out = f"error: {c['function']['name']} could not run ({type(e).__name__}: {e}). Check its arguments and call it again." msgs.append({"role": "tool", "tool_call_id": c["id"], "content": out}) if hands.pr: break diff --git a/CHANGELOG.md b/CHANGELOG.md index 6389b50..eec4878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - The second fix round escalates to a different model from the first round: `claude:opus` with the Claude seats (it was `venice:kimi code`, and grok 4.7 before that). `TIME_VENTURE_ESCALATE` overrides it. ### Added +- A worker's tool call that is cut off at the output limit, or arrives with malformed arguments, is an error the worker reads on its next step instead of a crash that throws the run away. The first Claude venture builds (time#148, #149) died on `'content'`: a whole launch page written in one `write_file` ran past the output limit and arrived with its path and no content. The error says so and asks for smaller files. - Ponytail on prod: a seat worker (and so the venture builder) carries Ponytail's rules in its system prompt: the smallest complete change, reuse before new code, a check for new logic, never cut validation, security or accessibility, and one closing line on what it skipped. Vendored from DietrichGebert/ponytail (MIT) at `.github/time/rules/ponytail.md`, upstream commit 09c434a. Reviewers, Astra and the venture board never get it (a coding ruleset distorts answers that aren't a diff). `TIME_PONYTAIL=off` turns it off. It ran on hb-5's seats since 2026-10-12; prod had never had it. - Time retries its own parked cards. A card parked on `error` or `stuck` with no open PR is dispatched again on the next beat, up to `TIME_AUTO_RETRY` times (default 1; 0 leaves it to a human), instead of waiting for a human `/time retry`; the beats carry `auto_retry`, a seat worker resumes the card's branch with half as many turns again (`TIME_WORKER_STEPS` x 1.5), and a card with an open PR still goes to review. Dry-run retries nothing. The `stuck` and `error` comments say what Time will do next. - Council verdict: `TIME_REVIEW` takes a comma-separated list of seats (`claude:haiku, claude:opus`); every seat reviews each PR and posts as itself, and each identity's latest vote counts once (Time's own comments are one identity). `approve` when approves minus objects reaches `TIME_REVIEW_QUORUM` (default 1), `object` when objects exceed approves. `TIME_REVIEW_TIEBREAK` names one seat (e.g. `venice:kimi`) called only when every standing seat has voted and they split; if it doesn't settle it either, one "the council is split N–N; over to a human" comment with a `needs-human` beat. One seat and quorum 1 behave as before. Codex's ladder (`luna`, `terra`, `sol`, `astra`) is in `seats.json` on the CLI route (its review run lands with #79; the `astra` login is the Codex seat, not the planning tick).