From 6d6bb3a44d1384e8248d482f57de522dc800aade Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 2 Aug 2026 15:39:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(orchestration):=20GLM=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8C=85=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini 할당량 소진 시 등급별 GLM thinking 수준으로 전환해 Pi 실행 명령과 재시도 상태가 같은 실행 대상을 유지하도록 한다. --- .../orchestrate-agent-task-loop/SKILL.md | 10 +- .../scripts/dispatch.py | 69 ++++- .../scripts/execution_target_policy.py | 46 ++- .../scripts/select_execution_target.py | 153 +++++++-- .../tests/test_dispatch.py | 293 +++++++++--------- .../tests/test_execution_target_policy.py | 45 ++- .../tests/test_select_execution_target.py | 113 +++---- 7 files changed, 436 insertions(+), 293 deletions(-) diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md b/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md index c6f2b755..4afcb202 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md @@ -69,11 +69,11 @@ Treat Korean text inside code spans or fenced examples as exact runtime or file- | PLAN route | Worker | |---|---| | `local-G01`–`local-G06` | Pi `iop/ornith:35b`, thinking high | -| `local-G07`–`local-G08` | KST `[07:00,23:00)` agy `Gemini 3.6 Flash (Medium)`; `[23:00,07:00)` Pi `iop/laguna-s:2.1` | +| `local-G07`–`local-G08` | KST day/night agy `Gemini 3.6 Flash (High)` → Pi `iop/glm-5.2`, thinking high | | `local-G09`–`local-G10` | Claude `claude-opus-4-8`, effort xhigh | -| `cloud-G01`–`cloud-G02` | agy `Gemini 3.6 Flash (Low)` | -| `cloud-G03`–`cloud-G04` | agy `Gemini 3.6 Flash (Medium)` | -| `cloud-G05`–`cloud-G06` | agy `Gemini 3.6 Flash (High)` | +| `cloud-G01`–`cloud-G02` | Codex `gpt-5.3-codex-spark` → agy `Gemini 3.6 Flash (Low)` → Pi `iop/glm-5.2`, thinking low | +| `cloud-G03`–`cloud-G04` | agy `Gemini 3.6 Flash (Medium)` → Pi `iop/glm-5.2`, thinking medium | +| `cloud-G05`–`cloud-G06` | agy `Gemini 3.6 Flash (High)` → Pi `iop/glm-5.2`, thinking high | | `cloud-G07`–`cloud-G08` | Claude `claude-opus-4-8`, effort xhigh | | `cloud-G09`–`cloud-G10` | Codex `gpt-5.6-sol`, reasoning xhigh | | Every `CODE_REVIEW-*` | Codex `gpt-5.6-sol`, reasoning xhigh | @@ -229,7 +229,7 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin - Archive `WORK_LOG.md` as `work_log_N.log` only after the final task review process exits, the dispatcher appends `FINISH`, and a complete scan finds no active/running task in that group. Accept the log at either the active group path or the verified completed single-task archive; do not impose either location contract on common plan/code-review. 3. **Escalate and recover context.** - - Escalate `agy -> Claude -> Codex` or `Claude -> Codex` only on terminal provider error events or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors. For AGY, accept top-level `error`, `fatal`, `request.failed`, or `turn.failed` events; failed/rejected status with a top-level error/code; stderr; or strong `RESOURCE_EXHAUSTED`, HTTP 429, quota, or rate-limit evidence in `agy-cli.log`. For Claude, classify a `rate_limit_event` with `rate_limit_info.status=rejected`, an error `result` with `api_error_status=429` or `error=rate_limit`, or a `You've hit your session limit · resets ...` terminal diagnostic as `provider-quota`. Never escalate from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log. + - For every route that lists Gemini followed by Pi GLM, classify terminal provider errors or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors as a qualified failover to that next GLM candidate. For AGY, accept top-level `error`, `fatal`, `request.failed`, or `turn.failed` events; failed/rejected status with a top-level error/code; stderr; or strong `RESOURCE_EXHAUSTED`, HTTP 429, quota, or rate-limit evidence in `agy-cli.log`. For Claude, classify a `rate_limit_event` with `rate_limit_info.status=rejected`, an error `result` with `api_error_status=429` or `error=rate_limit`, or a `You've hit your session limit · resets ...` terminal diagnostic as `provider-quota`. Cloud-only escalation remains `Claude -> Codex`; never escalate from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log. - Target Codex `gpt-5.6-terra` with reasoning `high` when escalating from Claude to Codex. - If Codex returns the same error, retry in a fresh Codex session using the locator while preserving the previous Codex model/reasoning and sharing the same stage's 10-consecutive-failure limit. Continue dispatching other tasks during recovery. - When current source reads a locator blocked 10 times as `generic-error` by older dispatcher source, collapse those 10 failures into one terminal error and clear only that task's blocker only if all 10 terminal-evidence records for the same task/plan/role/source/execution target reclassify to the same escalatable error. Include `stream.log` and the attempt's `agy-cli.log` for AGY. Do not adjust automatically when any history is missing or mixed, or when the locator dispatcher source hash equals the current source hash. Dry-run must display this escalation recovery and next model without writing state. Live execution must choose the higher target from the locator's actual failed target, not the initial PLAN route, inherit locator context, and restore the same escalation target and locator from persisted reclassification metadata after immediate restart. diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py index 9d991e1d..7edd4eb3 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py @@ -501,6 +501,7 @@ class AgentSpec: display: str local_pi: bool = False reasoning_effort: str | None = None + thinking_level: str | None = None def effective_reasoning_effort(spec: AgentSpec) -> str | None: @@ -509,6 +510,17 @@ def effective_reasoning_effort(spec: AgentSpec) -> str | None: return None +def effective_pi_thinking_level(spec: AgentSpec) -> str | None: + if spec.cli == "pi": + return spec.thinking_level or "high" + return None + + +def pi_display(model: str, thinking_level: str | None) -> str: + suffix = f" {thinking_level}" if thinking_level is not None else "" + return f"pi/iop/{model}{suffix}" + + def agent_spec_from_record(record: dict[str, Any]) -> AgentSpec | None: cli = str(record.get("cli") or "") model = str(record.get("model") or "") @@ -517,12 +529,15 @@ def agent_spec_from_record(record: dict[str, Any]) -> AgentSpec | None: reasoning_effort = record.get("reasoning_effort") if reasoning_effort is not None: reasoning_effort = str(reasoning_effort) + thinking_level = record.get("thinking_level") + if thinking_level is not None: + thinking_level = str(thinking_level) local_pi = cli == "pi" if cli in {"codex", "claude"}: effort = reasoning_effort or "xhigh" display = f"{cli}/{model} {effort}" elif cli == "pi": - display = f"pi/iop/{model}" + display = pi_display(model, thinking_level) else: display = f"{cli}/{model}" return AgentSpec( @@ -531,6 +546,7 @@ def agent_spec_from_record(record: dict[str, Any]) -> AgentSpec | None: display, local_pi=local_pi, reasoning_effort=reasoning_effort, + thinking_level=thinking_level, ) @@ -1730,6 +1746,7 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec: if not isinstance(selected, dict): raise ExecutionDecisionError("selector selected가 object가 아니다") adapter, target = selected.get("adapter"), selected.get("target") + thinking_level = selected.get("thinking_level") local_pi = selected.get("selfcheck_required") execution_class = selected.get("execution_class") if (not isinstance(adapter, str) or not isinstance(target, str) or not target @@ -1751,7 +1768,7 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec: lane=decision["lane"], grade=decision["grade"], ) - canonical = selector.policy.canonical_target(adapter, target) + canonical = selector.policy.canonical_target(adapter, target, thinking_level) except Exception as exc: raise ExecutionDecisionError(f"selector policy validation 실패: {exc}") from exc if canonical is None or ( @@ -1759,8 +1776,11 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec: or canonical.selfcheck_required != local_pi ): raise ExecutionDecisionError("selector selected가 canonical policy target이 아니다") - initial_keys = {(item.adapter, item.target) for item in policy_targets} - if (adapter, target) not in initial_keys: + initial_keys = { + (item.adapter, item.target, item.thinking_level) + for item in policy_targets + } + if (adapter, target, thinking_level) not in initial_keys: promotion_path = decision.get("promotion_path") if not isinstance(promotion_path, list) or len(promotion_path) < 2: raise ExecutionDecisionError("selector promotion path가 없다") @@ -1771,14 +1791,20 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec: f"selector promotion path[{index}]가 object가 아니다" ) resolved = selector.policy.canonical_target( - entry.get("adapter"), entry.get("target") + entry.get("adapter"), + entry.get("target"), + entry.get("thinking_level"), ) if resolved is None: raise ExecutionDecisionError( f"selector promotion path[{index}] target이 canonical이 아니다" ) resolved_path.append(resolved) - if (resolved_path[0].adapter, resolved_path[0].target) not in initial_keys: + if ( + resolved_path[0].adapter, + resolved_path[0].target, + resolved_path[0].thinking_level, + ) not in initial_keys: raise ExecutionDecisionError("selector promotion path 시작 target이 잘못됐다") if any( selector.policy.promotion_target(previous) != current @@ -1790,7 +1816,14 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec: if adapter == "pi": if not target.startswith("iop/") or not local_pi: raise ExecutionDecisionError("Pi selector target/schema가 유효하지 않다") - return AgentSpec("pi", target.removeprefix("iop/"), f"pi/{target}", local_pi=True) + model = target.removeprefix("iop/") + return AgentSpec( + "pi", + model, + pi_display(model, canonical.thinking_level), + local_pi=True, + thinking_level=canonical.thinking_level, + ) if adapter not in {"agy", "claude", "codex"} or local_pi: raise ExecutionDecisionError(f"selector adapter/schema가 유효하지 않다: {adapter!r}") reasoning_effort = "high" if canonical == selector.policy.CODEX_TERRA_HIGH else None @@ -1825,6 +1858,7 @@ def _spec_from_completing_decision(decision: dict[str, Any]) -> AgentSpec: target = selected.get("target") execution_class = selected.get("execution_class") selfcheck_required = selected.get("selfcheck_required") + thinking_level = selected.get("thinking_level") if not all(isinstance(value, str) and value for value in (adapter, target, execution_class)): raise ExecutionDecisionError( "completing decision selected의 adapter/target/execution_class는 빈 문자열이 아닌 string이어야 한다" @@ -1851,8 +1885,18 @@ def _spec_from_completing_decision(decision: dict[str, Any]) -> AgentSpec: "Pi completing decision selfcheck_required가 False이다" ) model = target.removeprefix("iop/") - display = f"pi/{target}" - return AgentSpec(adapter, model, display, local_pi=True) + if thinking_level is not None and thinking_level not in {"low", "medium", "high"}: + raise ExecutionDecisionError( + f"Pi completing decision thinking_level이 유효하지 않다: {thinking_level!r}" + ) + display = pi_display(model, thinking_level) + return AgentSpec( + adapter, + model, + display, + local_pi=True, + thinking_level=thinking_level, + ) if adapter not in {"agy", "claude", "codex"}: raise ExecutionDecisionError( f"completing decision adapter가 유효하지 않다: {adapter!r}" @@ -2818,6 +2862,10 @@ def legacy_promotion_recovery( agent_spec_from_record(record) or failed_spec ) != effective_reasoning_effort(failed_spec) + or effective_pi_thinking_level( + agent_spec_from_record(record) or failed_spec + ) + != effective_pi_thinking_level(failed_spec) or not isinstance(record.get("attempt"), int) ): continue @@ -3743,7 +3791,7 @@ def build_command( if spec.cli == "pi": command = [ "pi", "-p", "--mode", "json", "--approve", "--provider", "iop", "--model", spec.model, - "--thinking", "high", + "--thinking", str(effective_pi_thinking_level(spec)), ] if pi_resume_session is not None: command.extend( @@ -3849,6 +3897,7 @@ async def invoke( "cli": spec.cli, "model": spec.model, "reasoning_effort": effective_reasoning_effort(spec), + "thinking_level": effective_pi_thinking_level(spec), "agent_process_marker": process_marker, "plan_path": str(task.plan) if task.plan else None, "review_path": str(task.review) if task.review else None, diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py index 6028d622..6ce80e55 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py @@ -13,6 +13,7 @@ KST = ZoneInfo("Asia/Seoul") VALID_STAGES = {"worker", "review"} VALID_LANES = {"local", "cloud"} +VALID_PI_THINKING_LEVELS = frozenset({"low", "medium", "high"}) @dataclass(frozen=True) @@ -21,6 +22,7 @@ class RouteTarget: target: str execution_class: str selfcheck_required: bool + thinking_level: str | None = None @dataclass(frozen=True) @@ -43,6 +45,15 @@ AGY_GEMINI_HIGH = RouteTarget( "agy", "Gemini 3.6 Flash (High)", "cloud_model", False ) PI_LAGUNA = RouteTarget("pi", "iop/laguna-s:2.1", "local_model", True) +PI_GLM_LOW = RouteTarget( + "pi", "iop/glm-5.2", "local_model", True, thinking_level="low" +) +PI_GLM_MEDIUM = RouteTarget( + "pi", "iop/glm-5.2", "local_model", True, thinking_level="medium" +) +PI_GLM_HIGH = RouteTarget( + "pi", "iop/glm-5.2", "local_model", True, thinking_level="high" +) CLAUDE_OPUS = RouteTarget("claude", "claude-opus-4-8", "cloud_model", False) CLAUDE_HAIKU_XHIGH = RouteTarget( "claude", "claude-haiku-4-5", "cloud_model", False @@ -60,6 +71,9 @@ CANONICAL_TARGETS = ( AGY_GEMINI_MEDIUM, AGY_GEMINI_HIGH, PI_LAGUNA, + PI_GLM_LOW, + PI_GLM_MEDIUM, + PI_GLM_HIGH, CLAUDE_OPUS, CLAUDE_HAIKU_XHIGH, CODEX_SPARK_XHIGH, @@ -68,13 +82,19 @@ CANONICAL_TARGETS = ( ) -def canonical_target(adapter: str, target: str) -> RouteTarget | None: - """Resolve one policy-owned adapter + target identity.""" +def canonical_target( + adapter: str, target: str, thinking_level: str | None = None, +) -> RouteTarget | None: + """Resolve one policy-owned adapter + target + thinking identity.""" return next( ( candidate for candidate in CANONICAL_TARGETS - if candidate.adapter == adapter and candidate.target == target + if ( + candidate.adapter == adapter + and candidate.target == target + and candidate.thinking_level == thinking_level + ) ), None, ) @@ -82,12 +102,6 @@ def canonical_target(adapter: str, target: str) -> RouteTarget | None: def promotion_target(current: RouteTarget) -> RouteTarget | None: """Return the next target in the policy-owned cloud promotion chain.""" - if current.adapter == "agy" and current in { - AGY_GEMINI_LOW, - AGY_GEMINI_MEDIUM, - AGY_GEMINI_HIGH, - }: - return CLAUDE_OPUS if current == CLAUDE_OPUS: return CODEX_TERRA_HIGH return None @@ -166,12 +180,12 @@ def select_policy( time_window = _kst_time_window(evaluated_at) if time_window == "kst-day-[07:00,23:00)": rule_id = "worker-local-g07-g08-kst-day" - reason_code = "kst_day_gemini_medium" - candidates = (AGY_GEMINI_MEDIUM, PI_LAGUNA) + reason_code = "kst_day_gemini_high" + candidates = (AGY_GEMINI_HIGH, PI_GLM_HIGH) else: rule_id = "worker-local-g07-g08-kst-night" - reason_code = "kst_night_laguna" - candidates = (PI_LAGUNA, AGY_GEMINI_MEDIUM) + reason_code = "kst_night_gemini_high" + candidates = (AGY_GEMINI_HIGH, PI_GLM_HIGH) return PolicyDecision( rule_id=rule_id, policy_priority=20, @@ -191,16 +205,16 @@ def select_policy( candidates = ( CODEX_SPARK_XHIGH, AGY_GEMINI_LOW, - CLAUDE_HAIKU_XHIGH, + PI_GLM_LOW, ) rule_id = "worker-cloud-g01-g02" reason_code = "cloud_spark_priority_grade" elif grade <= 4: - candidates = (AGY_GEMINI_MEDIUM,) + candidates = (AGY_GEMINI_MEDIUM, PI_GLM_MEDIUM) rule_id = "worker-cloud-g03-g04" reason_code = "cloud_gemini_medium_grade" elif grade <= 6: - candidates = (AGY_GEMINI_HIGH,) + candidates = (AGY_GEMINI_HIGH, PI_GLM_HIGH) rule_id = "worker-cloud-g05-g06" reason_code = "cloud_gemini_high_grade" elif grade <= 8: diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py index 56c04b72..5bd527b1 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py @@ -181,6 +181,16 @@ def _validate_prior_selected(selected: object) -> None: code, "prior_decision.selected.selfcheck_required must be a boolean", ) + thinking_level = selected.get("thinking_level") + if thinking_level is not None and ( + not isinstance(thinking_level, str) + or thinking_level not in policy.VALID_PI_THINKING_LEVELS + ): + raise SelectorInputError( + code, + "prior_decision.selected.thinking_level must be null or one of " + f"{sorted(policy.VALID_PI_THINKING_LEVELS)}", + ) def _require_non_empty_string( @@ -252,6 +262,16 @@ def _validate_prior_candidates(candidates: object) -> None: raise SelectorInputError( code, f"{prefix}.selfcheck_required must be a boolean" ) + thinking_level = entry.get("thinking_level") + if thinking_level is not None and ( + not isinstance(thinking_level, str) + or thinking_level not in policy.VALID_PI_THINKING_LEVELS + ): + raise SelectorInputError( + code, + f"{prefix}.thinking_level must be null or one of " + f"{sorted(policy.VALID_PI_THINKING_LEVELS)}", + ) _require_string_enum(entry, "quota_mode", _VALID_QUOTA_MODES, prefix, code) _require_string_enum( entry, "quota_status", _VALID_QUOTA_STATUSES, prefix, code @@ -823,19 +843,20 @@ def _initial( target, quota_snapshot, evaluated_at, quota_probe_command ) eligible = status != "exhausted" - candidates.append( - { - "candidate_rank": rank, - "adapter": target.adapter, - "target": target.target, - "execution_class": target.execution_class, - "selfcheck_required": target.selfcheck_required, - "quota_mode": mode, - "quota_status": status, - "eligibility": "eligible" if eligible else "ineligible", - "rejection_reason": None if eligible else "quota_exhausted", - } - ) + candidate = { + "candidate_rank": rank, + "adapter": target.adapter, + "target": target.target, + "execution_class": target.execution_class, + "selfcheck_required": target.selfcheck_required, + "quota_mode": mode, + "quota_status": status, + "eligibility": "eligible" if eligible else "ineligible", + "rejection_reason": None if eligible else "quota_exhausted", + } + if target.thinking_level is not None: + candidate["thinking_level"] = target.thinking_level + candidates.append(candidate) if eligible and selected is None: selected = target selected_probed_snapshot = probed_snapshot @@ -844,18 +865,21 @@ def _initial( "no_eligible_target", "all policy candidates are exhausted according to the quota snapshot", ) + selected_fields = { + "adapter": selected.adapter, + "target": selected.target, + "execution_class": selected.execution_class, + "selfcheck_required": selected.selfcheck_required, + } + if selected.thinking_level is not None: + selected_fields["thinking_level"] = selected.thinking_level return { "schema_version": SCHEMA_VERSION, "work_unit_id": work_unit_id, "stage": stage, "lane": lane, "grade": grade, - "selected": { - "adapter": selected.adapter, - "target": selected.target, - "execution_class": selected.execution_class, - "selfcheck_required": selected.selfcheck_required, - }, + "selected": selected_fields, "candidates": candidates, "decision": { "rule_id": decision.rule_id, @@ -888,8 +912,14 @@ def _validate_selected_and_used_history( if not isinstance(selected, dict): raise SelectorInputError(code, "prior_decision.selected must be an object") - sel_key = (selected.get("adapter"), selected.get("target")) - canon_keys_list = [(c.adapter, c.target) for c in canonical_targets] + sel_key = ( + selected.get("adapter"), + selected.get("target"), + selected.get("thinking_level"), + ) + canon_keys_list = [ + (c.adapter, c.target, c.thinking_level) for c in canonical_targets + ] canon_keys_set = set(canon_keys_list) matching_cand = policy.canonical_target(*sel_key) @@ -921,14 +951,20 @@ def _validate_selected_and_used_history( code, f"promotion_path[{index}] must be an object" ) target = policy.canonical_target( - entry.get("adapter"), entry.get("target") + entry.get("adapter"), + entry.get("target"), + entry.get("thinking_level"), ) if target is None: raise SelectorInputError( code, f"promotion_path[{index}] is not policy-owned" ) path_targets.append(target) - if (path_targets[0].adapter, path_targets[0].target) not in canon_keys_set: + if ( + path_targets[0].adapter, + path_targets[0].target, + path_targets[0].thinking_level, + ) not in canon_keys_set: raise SelectorInputError( code, "promotion_path must begin at the initial policy target" ) @@ -958,7 +994,11 @@ def _validate_selected_and_used_history( raise SelectorInputError( code, f"prior_decision.used_candidates[{idx}] must be an object" ) - u_key = (entry.get("adapter"), entry.get("target")) + u_key = ( + entry.get("adapter"), + entry.get("target"), + entry.get("thinking_level"), + ) if u_key not in canon_keys_set: raise SelectorInputError( code, @@ -985,7 +1025,11 @@ def _validate_selected_and_used_history( else: prior_cands = prior_decision.get("candidates", []) eligible_cands = [ - (c.get("adapter"), c.get("target")) + ( + c.get("adapter"), + c.get("target"), + c.get("thinking_level"), + ) for c in prior_cands if isinstance(c, dict) and c.get("eligibility") == "eligible" ] @@ -1073,6 +1117,7 @@ def _validate_prior_candidate_identity( or p_cand.get("target") != c_target.target or p_cand.get("execution_class") != c_target.execution_class or p_cand.get("selfcheck_required") != c_target.selfcheck_required + or p_cand.get("thinking_level") != c_target.thinking_level ): raise SelectorInputError( code, @@ -1135,7 +1180,10 @@ def _resume( def _target_ref(candidate: dict) -> dict: - return {"adapter": candidate["adapter"], "target": candidate["target"]} + ref = {"adapter": candidate["adapter"], "target": candidate["target"]} + if candidate.get("thinking_level") is not None: + ref["thinking_level"] = candidate["thinking_level"] + return ref def _validate_used_candidates(value: object) -> list[dict]: @@ -1150,7 +1198,21 @@ def _validate_used_candidates(value: object) -> list[dict]: adapter, target = entry.get("adapter"), entry.get("target") if not isinstance(adapter, str) or not adapter or not isinstance(target, str) or not target: raise SelectorInputError("malformed_prior_decision", f"used_candidates[{index}] needs adapter and target") - refs.append({"adapter": adapter, "target": target}) + thinking_level = entry.get("thinking_level") + if thinking_level is not None and ( + not isinstance(thinking_level, str) + or thinking_level not in policy.VALID_PI_THINKING_LEVELS + ): + raise SelectorInputError( + "malformed_prior_decision", + "used_candidates[" + f"{index}].thinking_level must be null or one of " + f"{sorted(policy.VALID_PI_THINKING_LEVELS)}", + ) + ref = {"adapter": adapter, "target": target} + if thinking_level is not None: + ref["thinking_level"] = thinking_level + refs.append(ref) return refs @@ -1215,7 +1277,17 @@ def _failover( selected_probed_snapshot = current_snapshot if selected_candidate is None: raise SelectorInputError("no_failover_candidate", "no unused eligible candidate remains for this work unit") - selected = {field: selected_candidate[field] for field in ("adapter", "target", "execution_class", "selfcheck_required")} + selected = { + field: selected_candidate[field] + for field in ( + "adapter", + "target", + "execution_class", + "selfcheck_required", + ) + } + if selected_candidate.get("thinking_level") is not None: + selected["thinking_level"] = selected_candidate["thinking_level"] next_target = _target_ref(selected) used.append(next_target) decision = dict(prior["decision"]) @@ -1284,7 +1356,9 @@ def _promotion( "multi-candidate policy routes use failover instead of promotion", ) current = policy.canonical_target( - prior["selected"]["adapter"], prior["selected"]["target"] + prior["selected"]["adapter"], + prior["selected"]["target"], + prior["selected"].get("thinking_level"), ) promoted = policy.promotion_target(current) if current is not None else None if promoted is None: @@ -1292,8 +1366,20 @@ def _promotion( "no_promotion_target", "no unused canonical promotion target remains for this work unit", ) - previous_target = {"adapter": current.adapter, "target": current.target} - next_target = {"adapter": promoted.adapter, "target": promoted.target} + previous_target = _target_ref( + { + "adapter": current.adapter, + "target": current.target, + "thinking_level": current.thinking_level, + } + ) + next_target = _target_ref( + { + "adapter": promoted.adapter, + "target": promoted.target, + "thinking_level": promoted.thinking_level, + } + ) promotion_path = list(prior.get("promotion_path", [previous_target])) if not promotion_path or promotion_path[-1] != previous_target: raise SelectorInputError( @@ -1314,6 +1400,11 @@ def _promotion( "target": promoted.target, "execution_class": promoted.execution_class, "selfcheck_required": promoted.selfcheck_required, + **( + {"thinking_level": promoted.thinking_level} + if promoted.thinking_level is not None + else {} + ), }, "candidates": prior["candidates"], "decision": decision, diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py index d2b1832d..7e6fc46e 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py @@ -120,6 +120,26 @@ class CommandConstructionTest(unittest.TestCase): command[-2:], ["--log-file", str(workspace / "attempt" / "agy-cli.log")] ) + def test_pi_glm_preserves_policy_thinking_level(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + command = dispatch.build_command( + dispatch.AgentSpec( + "pi", + "glm-5.2", + "pi/iop/glm-5.2 medium", + local_pi=True, + thinking_level="medium", + ), + "Implement the active plan.", + workspace, + "test-session", + workspace / "attempt", + ) + + thinking_index = command.index("--thinking") + self.assertEqual(command[thinking_index + 1], "medium") + class TaskStageTest(unittest.TestCase): def make_task(self, root: Path, review_text: str = ""): @@ -467,11 +487,11 @@ class TaskStageTest(unittest.TestCase): day_dec = dispatch.select_execution_decision(task, stage="worker", evaluated_at=daytime) self.assertEqual(day_dec["selected"]["adapter"], "agy") - self.assertEqual(day_dec["selected"]["target"], "Gemini 3.6 Flash (Medium)") + self.assertEqual(day_dec["selected"]["target"], "Gemini 3.6 Flash (High)") night_dec = dispatch.select_execution_decision(task, stage="worker", evaluated_at=nighttime) - self.assertEqual(night_dec["selected"]["adapter"], "pi") - self.assertEqual(night_dec["selected"]["target"], "iop/laguna-s:2.1") + self.assertEqual(night_dec["selected"]["adapter"], "agy") + self.assertEqual(night_dec["selected"]["target"], "Gemini 3.6 Flash (High)") @@ -8276,7 +8296,7 @@ class DynamicFailoverBudgetTest(unittest.TestCase): self.assertEqual(worker_budget2.count(), 10) self.assertEqual(review_budget2.count(), 0) raw_entry = state2.get("stage_failure_budgets", {}).get(worker_budget2.key, {}) - self.assertEqual(raw_entry.get("last_target"), {"adapter": "pi", "target": "iop/laguna-s:2.1"}) + self.assertEqual(raw_entry.get("last_target"), {"adapter": "pi", "target": "iop/glm-5.2"}) self.assertEqual(raw_entry.get("last_transition"), "provider-quota") self.assertEqual(state2["execution_decisions"], decisions1) self.assertEqual([h["transition"] for h in state2["route_transition_history"]], ["initial", "provider-quota"]) @@ -8382,7 +8402,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas locator.write_text(json.dumps(record), encoding="utf-8") return locator - async def test_cloud_g01_g02_quota_failover_runs_spark_gemini_haiku(self): + async def test_cloud_g01_g02_quota_failover_runs_spark_gemini_glm_low(self): daytime = datetime( 2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)) ) @@ -8431,10 +8451,12 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas "Gemini 3.6 Flash (Low)", "agy/Gemini 3.6 Flash (Low)", ), - "claude": dispatch.AgentSpec( - "claude", - "claude-haiku-4-5", - "claude/claude-haiku-4-5 xhigh", + "pi": dispatch.AgentSpec( + "pi", + "glm-5.2", + "pi/iop/glm-5.2 low", + local_pi=True, + thinking_level="low", ), } locators = { @@ -8446,7 +8468,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas async def mock_invoke(*args, **kwargs): spec = args[4] invoked_specs.append(spec) - if spec.cli == "claude": + if spec.cli == "pi": return 0, None, locators[spec.cli] return 1, "provider-quota", locators[spec.cli] @@ -8467,13 +8489,13 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas ) self.assertTrue(success) - self.assertEqual(final_locator, locators["claude"]) + self.assertEqual(final_locator, locators["pi"]) self.assertEqual( [(spec.cli, spec.model) for spec in invoked_specs], [ ("codex", "gpt-5.3-codex-spark"), ("agy", "Gemini 3.6 Flash (Low)"), - ("claude", "claude-haiku-4-5"), + ("pi", "glm-5.2"), ], ) decision = store.task_state(task)["execution_decisions"]["worker"] @@ -8482,7 +8504,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas [ {"adapter": "codex", "target": "gpt-5.3-codex-spark"}, {"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"}, - {"adapter": "claude", "target": "claude-haiku-4-5"}, + {"adapter": "pi", "target": "iop/glm-5.2", "thinking_level": "low"}, ], ) finally: @@ -8693,7 +8715,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas finally: store.close() - async def test_cloud_agy_promotion_chain_commits_each_transition(self): + async def test_cloud_agy_quota_failover_commits_glm_high(self): daytime = datetime( 2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)) ) @@ -8708,20 +8730,16 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)", ) - claude_spec = dispatch.AgentSpec( - "claude", - "claude-opus-4-8", - "claude/claude-opus-4-8 xhigh", - ) - terra_spec = dispatch.AgentSpec( - "codex", - "gpt-5.6-terra", - "codex/gpt-5.6-terra high", - reasoning_effort="high", + glm_spec = dispatch.AgentSpec( + "pi", + "glm-5.2", + "pi/iop/glm-5.2 high", + local_pi=True, + thinking_level="high", ) locators = { spec.cli: self.make_attempt_locator(workspace, task, spec) - for spec in (agy_spec, claude_spec, terra_spec) + for spec in (agy_spec, glm_spec) } invoked_specs = [] invoked_prompts = [] @@ -8739,9 +8757,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas store, task, decision ) transition_budget_counts.append(budget.count()) - if spec.cli == "claude": - return (1, "context-limit", locators["claude"]) - return (0, None, locators["codex"]) + return (0, None, locators["pi"]) with ( mock.patch.object(dispatch, "invoke", new=mock_invoke), @@ -8757,33 +8773,31 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas ) self.assertTrue(success) - self.assertEqual(final_locator, locators["codex"]) - self.assertEqual( - invoked_specs, [agy_spec, claude_spec, terra_spec] - ) - self.assertEqual(transition_budget_counts, [1, 2]) + self.assertEqual(final_locator, locators["pi"]) + self.assertEqual(invoked_specs, [agy_spec, glm_spec]) + self.assertEqual(transition_budget_counts, [1]) self.assertIn( str(locators["agy"].resolve()), invoked_prompts[1] ) - self.assertIn( - str(locators["claude"].resolve()), invoked_prompts[2] - ) state = store.task_state(task) decision = state["execution_decisions"]["worker"] self.assertEqual( - decision["promotion_path"], + decision["used_candidates"], [ { "adapter": "agy", "target": "Gemini 3.6 Flash (High)", }, - {"adapter": "claude", "target": "claude-opus-4-8"}, - {"adapter": "codex", "target": "gpt-5.6-terra"}, + { + "adapter": "pi", + "target": "iop/glm-5.2", + "thinking_level": "high", + }, ], ) self.assertEqual( [entry["transition"] for entry in state["route_transition_history"]], - ["initial", "provider-quota", "context-limit"], + ["initial", "provider-quota"], ) self.assertEqual( dispatch.StageFailureBudget.from_decision( @@ -8794,7 +8808,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas finally: store.close() - async def test_night_laguna_failure_continues_on_available_gemini(self): + async def test_night_gemini_quota_fails_over_to_glm_high(self): nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9))) with tempfile.TemporaryDirectory() as temporary: workspace = Path(temporary) @@ -8802,15 +8816,19 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas task = self.make_task(workspace) store = dispatch.StateStore(workspace) try: - laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True) - locator = self.make_attempt_locator(workspace, task, laguna_spec) + gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)") + glm_spec = dispatch.AgentSpec( + "pi", "glm-5.2", "pi/iop/glm-5.2 high", + local_pi=True, thinking_level="high", + ) + locator = self.make_attempt_locator(workspace, task, gemini_spec) invoked_specs = [] async def mock_invoke(*args, **kwargs): spec = args[4] invoked_specs.append(spec) - if spec.cli == "pi": - return (1, "provider-stream-disconnect", locator) + if spec.cli == "agy": + return (1, "provider-quota", locator) return (0, None, locator) with ( @@ -8818,20 +8836,21 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()), ): dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=nighttime) - success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", laguna_spec) + success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", gemini_spec) self.assertTrue(success) self.assertEqual(len(invoked_specs), 2) - self.assertEqual(invoked_specs[0].cli, "pi") - self.assertEqual(invoked_specs[1].cli, "agy") + self.assertEqual(invoked_specs, [gemini_spec, glm_spec]) state = store.task_state(task) decisions = state["execution_decisions"]["worker"] - self.assertEqual(decisions["selected"]["adapter"], "agy") - self.assertEqual(decisions["transition"]["trigger"], "provider-stream-disconnect") + self.assertEqual(decisions["selected"]["adapter"], "pi") + self.assertEqual(decisions["selected"]["target"], "iop/glm-5.2") + self.assertEqual(decisions["selected"]["thinking_level"], "high") + self.assertEqual(decisions["transition"]["trigger"], "provider-quota") finally: store.close() - async def test_night_gemini_quota_exhaustion_blocks_without_bounce(self): + async def test_night_gemini_quota_initially_exhausted_selects_glm_high(self): nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9))) with tempfile.TemporaryDirectory() as temporary: workspace = Path(temporary) @@ -8839,18 +8858,21 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas task = self.make_task(workspace) store = dispatch.StateStore(workspace) try: - laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True) - locator = self.make_attempt_locator(workspace, task, laguna_spec) + glm_spec = dispatch.AgentSpec( + "pi", "glm-5.2", "pi/iop/glm-5.2 high", + local_pi=True, thinking_level="high", + ) + locator = self.make_attempt_locator(workspace, task, glm_spec) invoked_specs = [] async def mock_invoke(*args, **kwargs): spec = args[4] invoked_specs.append(spec) - return (1, "provider-quota", locator) + return (0, None, locator) quota_snap = { "targets": [ - {"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)", "status": "exhausted"} + {"adapter": "agy", "target": "Gemini 3.6 Flash (High)", "status": "exhausted"} ] } store.update_task(task, quota_snapshot=quota_snap) @@ -8859,12 +8881,14 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas mock.patch.object(dispatch, "invoke", new=mock_invoke), mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()), ): - dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=nighttime) - success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", laguna_spec) + _, selected = dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=nighttime) + success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", selected) - self.assertFalse(success) + self.assertTrue(success) + self.assertEqual(invoked_specs, [glm_spec]) state = store.task_state(task) - self.assertIn("no_failover_candidate", state.get("blocked", "")) + self.assertEqual(state["execution_decisions"]["worker"]["selected"]["thinking_level"], "high") + self.assertIsNone(state.get("blocked")) finally: store.close() @@ -8883,16 +8907,19 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas "source": "iop-node quota-probe", "checked_at": "2026-07-25T03:00:00+09:00", "targets": [ - {"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)", "status": "exhausted"} + {"adapter": "agy", "target": "Gemini 3.6 Flash (High)", "status": "exhausted"} ], }, ) - laguna_spec = dispatch.AgentSpec("pi", "iop/laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True) + glm_spec = dispatch.AgentSpec( + "pi", "glm-5.2", "pi/iop/glm-5.2 high", + local_pi=True, thinking_level="high", + ) decision, spec = dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime) - self.assertEqual(spec.cli, "pi") + self.assertEqual(spec, glm_spec) - locator = self.make_attempt_locator(workspace, task, laguna_spec) + locator = self.make_attempt_locator(workspace, task, glm_spec) store.update_task( task, @@ -8901,7 +8928,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas "source": "iop-node quota-probe", "checked_at": "2026-07-25T04:00:00+09:00", "targets": [ - {"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)", "status": "available"} + {"adapter": "agy", "target": "Gemini 3.6 Flash (High)", "status": "available"} ], }, ) @@ -8916,7 +8943,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas mock.patch.object(dispatch, "invoke", new=mock_invoke), mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()), ): - success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", laguna_spec) + success, final_loc = await dispatch.run_escalating(workspace, store, task, "worker", glm_spec) self.assertFalse(success) self.assertEqual(len(invoked_specs), 1) @@ -9043,13 +9070,8 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas finally: store.close() - async def test_promotion_chain_exhaustion_stays_on_last_target(self): - """Cloud promotion chain: AGY→Claude→Terra exhausted. - - When the last canonical target (Terra) fails with a promotable failure - and no promotion target remains, the worker must stay on Terra for - same-target recovery rather than falling through to legacy promoted_spec(). - """ + async def test_cloud_g05_g06_gemini_quota_fails_over_to_glm_high(self): + """Cloud G05–G06 sends qualified Gemini failures to Pi GLM High.""" daytime = datetime( 2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)) ) @@ -9062,16 +9084,12 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas agy_spec = dispatch.AgentSpec( "agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)" ) - claude_spec = dispatch.AgentSpec( - "claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh" - ) - terra_spec = dispatch.AgentSpec( - "codex", "gpt-5.6-terra", "codex/gpt-5.6-terra high", - reasoning_effort="high", + glm_spec = dispatch.AgentSpec( + "pi", "glm-5.2", "pi/iop/glm-5.2 high", + local_pi=True, thinking_level="high", ) loc_agy = self.make_attempt_locator(workspace, task, agy_spec) - loc_claude = self.make_attempt_locator(workspace, task, claude_spec) - loc_terra = self.make_attempt_locator(workspace, task, terra_spec) + loc_glm = self.make_attempt_locator(workspace, task, glm_spec) invoked_specs = [] async def mock_invoke(*args, **kwargs): @@ -9079,13 +9097,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas invoked_specs.append(spec) if spec.cli == "agy": return (1, "provider-quota", loc_agy) - if spec.cli == "claude": - return (1, "context-limit", loc_claude) - # Terra fails once, then succeeds — chain exhaustion keeps it on Terra - terra_count = sum(1 for s in invoked_specs if s.cli == "codex") - if terra_count == 1: - return (1, "provider-quota", loc_terra) - return (0, None, loc_terra) + return (0, None, loc_glm) with ( mock.patch.object(dispatch, "invoke", new=mock_invoke), @@ -9098,34 +9110,27 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas workspace, store, task, "worker", agy_spec ) - # Chain: AGY → Claude → Terra, then Terra retries on same target (no legacy fallthrough) + self.assertTrue(success) self.assertEqual( [s.cli for s in invoked_specs], - ["agy", "claude", "codex", "codex"], + ["agy", "pi"], ) - # The third and fourth invocations are both Terra (same-target recovery) - self.assertEqual(invoked_specs[2].cli, "codex") - self.assertEqual(invoked_specs[2].model, "gpt-5.6-terra") - self.assertEqual(invoked_specs[3].cli, "codex") - self.assertEqual(invoked_specs[3].model, "gpt-5.6-terra") + self.assertEqual(invoked_specs[1], glm_spec) state = store.task_state(task) decision = state["execution_decisions"]["worker"] - # Promotion path should include all three transitions self.assertEqual( - len(decision["promotion_path"]), 3, + decision["used_candidates"], + [ + {"adapter": "agy", "target": "Gemini 3.6 Flash (High)"}, + {"adapter": "pi", "target": "iop/glm-5.2", "thinking_level": "high"}, + ], ) - # History should show the promotions but no legacy recovery transitions = [h["transition"] for h in state["route_transition_history"]] self.assertIn("provider-quota", transitions) - self.assertIn("context-limit", transitions) - # No legacy promoted_spec() fallthrough: selected stays on Terra - self.assertEqual( - decision["selected"]["adapter"], "codex" - ) - self.assertEqual( - decision["selected"]["target"], "gpt-5.6-terra" - ) + self.assertEqual(decision["selected"]["adapter"], "pi") + self.assertEqual(decision["selected"]["target"], "iop/glm-5.2") + self.assertEqual(decision["selected"]["thinking_level"], "high") finally: store.close() @@ -9261,14 +9266,14 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(call_args[3], "worker") spec_worker = call_args[4] self.assertEqual(spec_worker.cli, "agy") - self.assertEqual(spec_worker.model, "Gemini 3.6 Flash (Medium)") + self.assertEqual(spec_worker.model, "Gemini 3.6 Flash (High)") state_after_worker = store.task_state(task) self.assertTrue(state_after_worker.get("worker_done")) self.assertIn("worker", state_after_worker.get("execution_decisions", {})) self.assertEqual( state_after_worker["execution_decisions"]["worker"]["selected"]["target"], - "Gemini 3.6 Flash (Medium)", + "Gemini 3.6 Flash (High)", ) await dispatch.run_review(workspace, store, task) @@ -9371,7 +9376,7 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): task1_banners = [b for b in banners if b[1] == task1.name] task2_banners = [b for b in banners if b[1] == task2.name] self.assertTrue(any("model=agy/" in line for b in task1_banners for line in b[2])) - self.assertTrue(any("model=pi/" in line for b in task2_banners for line in b[2])) + self.assertTrue(any("model=agy/" in line for b in task2_banners for line in b[2])) state1_after = store.task_state(task1) state2_after = store.task_state(task2) @@ -9397,21 +9402,21 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): task = self.make_task(workspace, lane="local", grade=8) store = dispatch.StateStore(workspace) try: - # 1. Initial decision daytime (KST 14:00) -> agy Gemini Medium + # 1. Initial decision daytime (KST 14:00) -> agy Gemini High dec1, spec1 = dispatch.persisted_execution_decision( store, task, stage="worker", evaluated_at=daytime ) self.assertEqual(spec1.cli, "agy") - self.assertEqual(spec1.model, "Gemini 3.6 Flash (Medium)") + self.assertEqual(spec1.model, "Gemini 3.6 Flash (High)") - # 2. Resuming at nighttime (KST 23:00) keeps pinned Gemini Medium + # 2. Resuming at nighttime (KST 23:00) keeps pinned Gemini High dec2, spec2 = dispatch.persisted_execution_decision( store, task, stage="worker", evaluated_at=nighttime ) self.assertEqual(spec2.cli, "agy") - self.assertEqual(spec2.model, "Gemini 3.6 Flash (Medium)") + self.assertEqual(spec2.model, "Gemini 3.6 Flash (High)") - # 3. Body edit (header intact) keeps pinned Gemini Medium + # 3. Body edit (header intact) keeps pinned Gemini High plan_file = task.plan header = f"\n" plan_file.write_text(header + "\n# Modified Body Content\n", encoding="utf-8") @@ -9420,14 +9425,14 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(spec3.cli, "agy") - # 4. New generation header (plan=1) re-evaluates initial decision at nighttime -> pi Laguna + # 4. New generation header (plan=1) re-evaluates initial decision at nighttime -> Gemini High plan_file.write_text("\n\n# New Plan\n", encoding="utf-8") task_new = dispatch.scan_tasks(workspace, None)[0] dec4, spec4 = dispatch.persisted_execution_decision( store, task_new, stage="worker", evaluated_at=nighttime ) - self.assertEqual(spec4.cli, "pi") - self.assertEqual(spec4.model, "laguna-s:2.1") + self.assertEqual(spec4.cli, "agy") + self.assertEqual(spec4.model, "Gemini 3.6 Flash (High)") finally: store.close() @@ -9907,17 +9912,20 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9))) nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9))) - # Case 1: Day local G08 completion on Laguna requires selfcheck with pinned Laguna + # Case 1: Day local G08 Gemini quota failover completes on pinned GLM High. with tempfile.TemporaryDirectory() as temporary: workspace = Path(temporary) (workspace / ".git").mkdir() task = self.make_task(workspace, lane="local", grade=8) store = dispatch.StateStore(workspace) try: - gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)") - laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True) + gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)") + glm_spec = dispatch.AgentSpec( + "pi", "glm-5.2", "pi/iop/glm-5.2 high", + local_pi=True, thinking_level="high", + ) loc_gemini = self.make_attempt_locator(workspace, task, gemini_spec) - loc_laguna = self.make_attempt_locator(workspace, task, laguna_spec) + loc_glm = self.make_attempt_locator(workspace, task, glm_spec) invoked_specs = [] async def mock_invoke(*args, **kwargs): @@ -9925,7 +9933,7 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): invoked_specs.append(spec) if spec.cli == "agy": return (1, "provider-quota", loc_gemini) - return (0, None, loc_laguna) + return (0, None, loc_glm) with ( mock.patch.object(dispatch, "invoke", new=mock_invoke), @@ -9950,7 +9958,7 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): async def mock_invoke_selfcheck(*args, **kwargs): spec = args[4] selfcheck_specs.append(spec) - return (0, None, loc_laguna) + return (0, None, loc_glm) with ( mock.patch.object(dispatch, "invoke", new=mock_invoke_selfcheck), @@ -9968,24 +9976,20 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): finally: store.close() - # Case 2: Night local G08 completion on Gemini skips selfcheck + # Case 2: Night local G08 remains on Gemini High and skips selfcheck. with tempfile.TemporaryDirectory() as temporary: workspace = Path(temporary) (workspace / ".git").mkdir() task = self.make_task(workspace, lane="local", grade=8) store = dispatch.StateStore(workspace) try: - gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)") - laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True) + gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)") loc_gemini = self.make_attempt_locator(workspace, task, gemini_spec) - loc_laguna = self.make_attempt_locator(workspace, task, laguna_spec) invoked_specs = [] async def mock_invoke(*args, **kwargs): spec = args[4] invoked_specs.append(spec) - if spec.cli == "pi": - return (1, "provider-stream-disconnect", loc_laguna) return (0, None, loc_gemini) with ( @@ -9995,12 +9999,12 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase): dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=nighttime) await dispatch.run_worker(workspace, store, task) - self.assertEqual([s.cli for s in invoked_specs], ["pi", "agy"]) + self.assertEqual([s.cli for s in invoked_specs], ["agy"]) state = store.task_state(task) self.assertEqual(state["execution_class"], "cloud_model") self.assertTrue(state["selfcheck_done"]) self.assertEqual(dispatch.task_stage(task, state), "review") - self.assertEqual([h["transition"] for h in state["route_transition_history"]], ["initial", "resume", "provider-stream-disconnect"]) + self.assertEqual([h["transition"] for h in state["route_transition_history"]], ["initial", "resume"]) finally: store.close() @@ -10213,7 +10217,7 @@ class ThroughputQuotaBatchTest(unittest.TestCase): finally: store.close() - def test_night_local_and_official_review_zero_probe_count(self): + def test_night_local_gemini_high_and_official_review_probe_count(self): with tempfile.TemporaryDirectory() as temporary: workspace = Path(temporary) (workspace / ".git").mkdir() @@ -10236,9 +10240,11 @@ class ThroughputQuotaBatchTest(unittest.TestCase): ready = [(t_night, "worker"), (t_review, "review")] batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now) - # Night local-G08 candidate is local_model first, official review stage is not worker -> 0 probes needed! - self.assertIsNone(batch_snap) - self.assertEqual(len(probe_calls), 0) + # Night local-G08 now starts on Gemini High; review remains excluded. + self.assertIsNotNone(batch_snap) + self.assertEqual(len(probe_calls), 1) + self.assertEqual(probe_calls[0]["adapter"], "agy") + self.assertEqual(probe_calls[0]["target"], "Gemini 3.6 Flash (High)") finally: store.close() @@ -10649,7 +10655,7 @@ class ThroughputQuotaBatchTest(unittest.TestCase): finally: store.close() - def test_retry_blocked_scopes_to_blocked_worker_and_refreshes_pinned_alternate(self): + def test_retry_blocked_scopes_to_blocked_worker_and_selects_glm_fallback(self): async def _async_run(): nighttime = datetime(2026, 7, 26, 23, 0, 0, tzinfo=timezone(timedelta(hours=9))) with tempfile.TemporaryDirectory() as temporary: @@ -10689,8 +10695,8 @@ class ThroughputQuotaBatchTest(unittest.TestCase): store, t_blocked, stage="worker", evaluated_at=nighttime, quota_snapshot=normal_snap ) run.assert_not_called() - self.assertEqual(d_blocked["selected"]["adapter"], "pi") - self.assertEqual(d_blocked["selected"]["target"], "iop/laguna-s:2.1") + self.assertEqual(d_blocked["selected"]["adapter"], "agy") + self.assertEqual(d_blocked["selected"]["target"], "Gemini 3.6 Flash (High)") loc_path = workspace / "attempt-loc.json" loc_path.write_text("{}", encoding="utf-8") @@ -10756,6 +10762,7 @@ class ThroughputQuotaBatchTest(unittest.TestCase): with mock.patch.object(selector, "probe_candidate_quota", side_effect=mock_probe), \ mock.patch.object(dispatch, "run_review", side_effect=fake_run_review), \ mock.patch.object(dispatch, "ensure_review_shared_state"), \ + mock.patch.object(dispatch, "implementation_review_errors", return_value=[]), \ mock.patch.object(dispatch, "invoke", side_effect=fake_invoke), \ mock.patch.object(dispatch, "datetime") as datetime_mock, \ mock.patch("subprocess.run", side_effect=AssertionError) as run_sub: @@ -10763,16 +10770,15 @@ class ThroughputQuotaBatchTest(unittest.TestCase): res = await dispatch.dispatch_with_store(args, workspace, store) run_sub.assert_not_called() - self.assertEqual(len(probe_calls), 1) - self.assertEqual(probe_calls[0]["adapter"], "agy") - self.assertEqual(probe_calls[0]["target"], "Gemini 3.6 Flash (Medium)") + self.assertEqual(len(probe_calls), 0) st_blocked_after = store.task_state(t_blocked) self.assertIsNone(st_blocked_after.get("blocked")) self.assertFalse(st_blocked_after.get("retry_quota_refresh_pending")) dec_after = st_blocked_after["execution_decisions"]["worker"] - self.assertEqual(dec_after["selected"]["adapter"], "agy") - self.assertEqual(dec_after["selected"]["target"], "Gemini 3.6 Flash (Medium)") + self.assertEqual(dec_after["selected"]["adapter"], "pi") + self.assertEqual(dec_after["selected"]["target"], "iop/glm-5.2") + self.assertEqual(dec_after["selected"]["thinking_level"], "high") self.assertEqual(dec_after["transition"]["trigger"], "provider-quota") self.assertEqual(dec_after["work_unit_id"], d_blocked["work_unit_id"]) @@ -10782,9 +10788,10 @@ class ThroughputQuotaBatchTest(unittest.TestCase): self.assertIn("agy", used_adapters) self.assertTrue(len(st_blocked_after.get("route_transition_history", [])) >= 2) blocked_invocations = [call for call in invoke_calls if call[0] == t_blocked.name] - self.assertEqual(len(blocked_invocations), 1) + self.assertEqual(len(blocked_invocations), 2) self.assertEqual(blocked_invocations[0][1], "worker") self.assertEqual(blocked_invocations[0][4], loc_path) + self.assertEqual(blocked_invocations[1][1], "selfcheck") st_normal_after = store.task_state(t_normal) self.assertFalse(st_normal_after.get("retry_quota_refresh_pending")) diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py index cefa9eac..fa81585e 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py @@ -25,10 +25,10 @@ def at_utc(hour: int, minute: int = 0, second: int = 0) -> datetime: class ExecutionTargetPolicyTests(unittest.TestCase): def test_local_g07_route_uses_kst_boundaries(self): cases = [ - (at_utc(21, 59, 59), "pi", "iop/laguna-s:2.1", "kst-night-[23:00,07:00)"), - (at_utc(22, 0, 0), "agy", "Gemini 3.6 Flash (Medium)", "kst-day-[07:00,23:00)"), - (at_utc(13, 59, 59), "agy", "Gemini 3.6 Flash (Medium)", "kst-day-[07:00,23:00)"), - (at_utc(14, 0, 0), "pi", "iop/laguna-s:2.1", "kst-night-[23:00,07:00)"), + (at_utc(21, 59, 59), "agy", "Gemini 3.6 Flash (High)", "kst-night-[23:00,07:00)"), + (at_utc(22, 0, 0), "agy", "Gemini 3.6 Flash (High)", "kst-day-[07:00,23:00)"), + (at_utc(13, 59, 59), "agy", "Gemini 3.6 Flash (High)", "kst-day-[07:00,23:00)"), + (at_utc(14, 0, 0), "agy", "Gemini 3.6 Flash (High)", "kst-night-[23:00,07:00)"), ] for evaluated_at, adapter, target, time_window in cases: with self.subTest(evaluated_at=evaluated_at): @@ -49,9 +49,9 @@ class ExecutionTargetPolicyTests(unittest.TestCase): stage="worker", lane="local", grade=8, evaluated_at=night_time ) self.assertEqual(decision.rule_id, "worker-local-g07-g08-kst-night") - self.assertEqual(decision.candidates, (policy.PI_LAGUNA, policy.AGY_GEMINI_MEDIUM)) + self.assertEqual(decision.candidates, (policy.AGY_GEMINI_HIGH, policy.PI_GLM_HIGH)) self.assertEqual(decision.time_window, "kst-night-[23:00,07:00)") - self.assertEqual(decision.candidates[0].target, "iop/laguna-s:2.1") + self.assertEqual(decision.candidates[0].target, "Gemini 3.6 Flash (High)") def test_worker_grade_matrix_has_no_gaps(self): daytime = at_utc(3) @@ -61,8 +61,8 @@ class ExecutionTargetPolicyTests(unittest.TestCase): grade: ("pi", "iop/ornith:35b", True) for grade in range(1, 7) }, - 7: ("agy", "Gemini 3.6 Flash (Medium)", False), - 8: ("agy", "Gemini 3.6 Flash (Medium)", False), + 7: ("agy", "Gemini 3.6 Flash (High)", False), + 8: ("agy", "Gemini 3.6 Flash (High)", False), 9: ("claude", "claude-opus-4-8", False), 10: ("claude", "claude-opus-4-8", False), }, @@ -103,7 +103,7 @@ class ExecutionTargetPolicyTests(unittest.TestCase): route, ) - def test_cloud_g01_g02_uses_ordered_spark_gemini_haiku_candidates(self): + def test_cloud_g01_g02_uses_ordered_spark_gemini_glm_candidates(self): for grade in (1, 2): with self.subTest(grade=grade): decision = policy.select_policy( @@ -117,7 +117,7 @@ class ExecutionTargetPolicyTests(unittest.TestCase): ( policy.CODEX_SPARK_XHIGH, policy.AGY_GEMINI_LOW, - policy.CLAUDE_HAIKU_XHIGH, + policy.PI_GLM_LOW, ), ) self.assertEqual( @@ -138,7 +138,7 @@ class ExecutionTargetPolicyTests(unittest.TestCase): self.assertEqual(decision.rule_id, "official-review-codex") self.assertEqual(decision.candidates, (policy.CODEX_SOL_XHIGH,)) - def test_local_g07_g08_candidate_order_uses_kst_boundaries(self): + def test_local_g07_g08_candidate_order_uses_gemini_high_then_glm_high(self): daytime = policy.select_policy( stage="worker", lane="local", @@ -151,14 +151,8 @@ class ExecutionTargetPolicyTests(unittest.TestCase): grade=8, evaluated_at=at_utc(15), ) - self.assertEqual( - [candidate.adapter for candidate in daytime.candidates], - ["agy", "pi"], - ) - self.assertEqual( - [candidate.adapter for candidate in nighttime.candidates], - ["pi", "agy"], - ) + self.assertEqual(daytime.candidates, (policy.AGY_GEMINI_HIGH, policy.PI_GLM_HIGH)) + self.assertEqual(nighttime.candidates, (policy.AGY_GEMINI_HIGH, policy.PI_GLM_HIGH)) def test_invalid_inputs_are_rejected(self): cases = [ @@ -184,9 +178,6 @@ class ExecutionTargetPolicyTests(unittest.TestCase): def test_cloud_promotion_matrix(self): cases = [ - (policy.AGY_GEMINI_LOW, policy.CLAUDE_OPUS), - (policy.AGY_GEMINI_MEDIUM, policy.CLAUDE_OPUS), - (policy.AGY_GEMINI_HIGH, policy.CLAUDE_OPUS), (policy.CLAUDE_OPUS, policy.CODEX_TERRA_HIGH), (policy.CLAUDE_HAIKU_XHIGH, None), (policy.CODEX_SPARK_XHIGH, None), @@ -194,6 +185,9 @@ class ExecutionTargetPolicyTests(unittest.TestCase): (policy.CODEX_TERRA_HIGH, None), (policy.PI_ORNITH, None), (policy.PI_LAGUNA, None), + (policy.PI_GLM_LOW, None), + (policy.PI_GLM_MEDIUM, None), + (policy.PI_GLM_HIGH, None), ] for current, expected in cases: with self.subTest(current=current): @@ -202,7 +196,9 @@ class ExecutionTargetPolicyTests(unittest.TestCase): for target in policy.CANONICAL_TARGETS: with self.subTest(identity=target.target): self.assertEqual( - policy.canonical_target(target.adapter, target.target), + policy.canonical_target( + target.adapter, target.target, target.thinking_level + ), target, ) self.assertIsNone(policy.canonical_target("codex", "unknown")) @@ -211,6 +207,9 @@ class ExecutionTargetPolicyTests(unittest.TestCase): cases = [ (policy.PI_ORNITH, None), (policy.PI_LAGUNA, None), + (policy.PI_GLM_LOW, None), + (policy.PI_GLM_MEDIUM, None), + (policy.PI_GLM_HIGH, None), ( policy.AGY_GEMINI_LOW, policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (Low)", ("overall", "model:Gemini 3.6 Flash (Low)")), diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py index 536d4358..dca7469e 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py @@ -356,12 +356,12 @@ class SelectorContractTests(unittest.TestCase): task_file, evaluated_at=kst(12) ) self.assertEqual(daytime["selected"]["adapter"], "agy") - # A new night route changes target, but resume preserves the pin. + # Day/night retain the same Gemini High primary, and resume remains pinned. night_initial = selector.select_execution_target( task_file, evaluated_at=kst(2) ) - self.assertEqual(night_initial["selected"]["adapter"], "pi") - self.assertEqual(night_initial["selected"]["target"], "iop/laguna-s:2.1") + self.assertEqual(night_initial["selected"]["adapter"], "agy") + self.assertEqual(night_initial["selected"]["target"], "Gemini 3.6 Flash (High)") resumed = selector.select_execution_target( task_file, evaluated_at=kst(2), @@ -373,7 +373,7 @@ class SelectorContractTests(unittest.TestCase): self.assertEqual(resumed["transition"]["trigger"], "resume") self.assertEqual( resumed["transition"]["previous_target"], - {"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)"}, + {"adapter": "agy", "target": "Gemini 3.6 Flash (High)"}, ) def test_resume_requires_matching_prior_decision(self): @@ -432,12 +432,12 @@ class SelectorContractTests(unittest.TestCase): class SelectorRouteMatrixTests(unittest.TestCase): - def test_local_g07_g08_use_kst_boundaries(self): + def test_local_g07_g08_use_gemini_high_on_both_kst_windows(self): cases = [ - (kst(6, 59, 59), "pi", "iop/laguna-s:2.1"), - (kst(7, 0, 0), "agy", "Gemini 3.6 Flash (Medium)"), - (kst(22, 59, 59), "agy", "Gemini 3.6 Flash (Medium)"), - (kst(23, 0, 0), "pi", "iop/laguna-s:2.1"), + (kst(6, 59, 59), "agy", "Gemini 3.6 Flash (High)"), + (kst(7, 0, 0), "agy", "Gemini 3.6 Flash (High)"), + (kst(22, 59, 59), "agy", "Gemini 3.6 Flash (High)"), + (kst(23, 0, 0), "agy", "Gemini 3.6 Flash (High)"), ] with TemporaryDirectory() as tmp: for grade in (7, 8): @@ -455,8 +455,8 @@ class SelectorRouteMatrixTests(unittest.TestCase): "local": { **{g: ("pi", "iop/ornith:35b", "local_model", True) for g in range(1, 7)}, - 7: ("agy", "Gemini 3.6 Flash (Medium)", "cloud_model", False), - 8: ("agy", "Gemini 3.6 Flash (Medium)", "cloud_model", False), + 7: ("agy", "Gemini 3.6 Flash (High)", "cloud_model", False), + 8: ("agy", "Gemini 3.6 Flash (High)", "cloud_model", False), 9: ("claude", "claude-opus-4-8", "cloud_model", False), 10: ("claude", "claude-opus-4-8", "cloud_model", False), }, @@ -530,13 +530,13 @@ class SelectorRouteMatrixTests(unittest.TestCase): [c["adapter"] for c in daytime], ["agy", "pi"] ) self.assertEqual( - [c["adapter"] for c in nighttime], ["pi", "agy"] + [c["adapter"] for c in nighttime], ["agy", "pi"] ) single = write_task_file(Path(tmp), "PLAN", "cloud", 5) candidates = selector.select_execution_target( single, evaluated_at=kst(12) )["candidates"] - self.assertEqual([c["candidate_rank"] for c in candidates], [1]) + self.assertEqual([c["candidate_rank"] for c in candidates], [1, 2]) class SelectorQuotaRepresentationTests(unittest.TestCase): @@ -641,7 +641,7 @@ class SelectorQuotaRepresentationTests(unittest.TestCase): result["quota"]["targets"], snapshot["targets"] ) - def test_exhausted_gemini_falls_back_to_laguna(self): + def test_exhausted_gemini_falls_back_to_glm_high(self): snapshot = { "snapshot_id": "gemini-exhausted", "source": "iop-node quota-probe", @@ -649,7 +649,7 @@ class SelectorQuotaRepresentationTests(unittest.TestCase): "targets": [ { "adapter": "agy", - "target": "Gemini 3.6 Flash (Medium)", + "target": "Gemini 3.6 Flash (High)", "status": "exhausted", } ], @@ -660,7 +660,8 @@ class SelectorQuotaRepresentationTests(unittest.TestCase): task_file, evaluated_at=kst(12), quota_snapshot=snapshot ) self.assertEqual(result["selected"]["adapter"], "pi") - self.assertEqual(result["selected"]["target"], "iop/laguna-s:2.1") + self.assertEqual(result["selected"]["target"], "iop/glm-5.2") + self.assertEqual(result["selected"]["thinking_level"], "high") def test_all_candidates_exhausted_returns_no_eligible_target(self): snapshot = { @@ -1148,7 +1149,7 @@ class SelectorIdentityAndQuotaRoundtripTests(unittest.TestCase): class SelectorFailoverContractTests(unittest.TestCase): - def test_cloud_g01_g02_quota_failover_follows_spark_gemini_haiku_order(self): + def test_cloud_g01_g02_quota_failover_follows_spark_gemini_glm_low_order(self): with TemporaryDirectory() as tmp: task_file = write_task_file(Path(tmp), "PLAN", "cloud", 1) initial = selector.select_execution_target( @@ -1164,7 +1165,7 @@ class SelectorFailoverContractTests(unittest.TestCase): failure_class="provider-quota", quota_probe_command="missing-probe", ) - haiku = selector.select_execution_target( + glm = selector.select_execution_target( task_file, evaluated_at=kst(12), transition="failover", @@ -1181,7 +1182,7 @@ class SelectorFailoverContractTests(unittest.TestCase): [ ("codex", "gpt-5.3-codex-spark"), ("agy", "Gemini 3.6 Flash (Low)"), - ("claude", "claude-haiku-4-5"), + ("pi", "iop/glm-5.2"), ], ) self.assertEqual( @@ -1189,15 +1190,16 @@ class SelectorFailoverContractTests(unittest.TestCase): ("agy", "Gemini 3.6 Flash (Low)"), ) self.assertEqual( - (haiku["selected"]["adapter"], haiku["selected"]["target"]), - ("claude", "claude-haiku-4-5"), + (glm["selected"]["adapter"], glm["selected"]["target"]), + ("pi", "iop/glm-5.2"), ) + self.assertEqual(glm["selected"]["thinking_level"], "low") self.assertEqual( - haiku["used_candidates"], + glm["used_candidates"], [ {"adapter": "codex", "target": "gpt-5.3-codex-spark"}, {"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"}, - {"adapter": "claude", "target": "claude-haiku-4-5"}, + {"adapter": "pi", "target": "iop/glm-5.2", "thinking_level": "low"}, ], ) with self.assertRaises(selector.SelectorInputError) as exhausted: @@ -1205,7 +1207,7 @@ class SelectorFailoverContractTests(unittest.TestCase): task_file, evaluated_at=kst(12), transition="failover", - prior_decision=haiku, + prior_decision=glm, failure_class="provider-quota", quota_probe_command="missing-probe", ) @@ -1266,7 +1268,7 @@ class SelectorFailoverContractTests(unittest.TestCase): "targets": [ { "adapter": "agy", - "target": "Gemini 3.6 Flash (Medium)", + "target": "Gemini 3.6 Flash (High)", "status": "exhausted", } ], @@ -1278,7 +1280,7 @@ class SelectorFailoverContractTests(unittest.TestCase): "targets": [ { "adapter": "agy", - "target": "Gemini 3.6 Flash (Medium)", + "target": "Gemini 3.6 Flash (High)", "status": "available", } ], @@ -1289,7 +1291,8 @@ class SelectorFailoverContractTests(unittest.TestCase): task_file, evaluated_at=kst(12), quota_snapshot=gemini_exhausted_snapshot ) self.assertEqual(prior["selected"]["adapter"], "pi") - self.assertEqual(prior["selected"]["target"], "iop/laguna-s:2.1") + self.assertEqual(prior["selected"]["target"], "iop/glm-5.2") + self.assertEqual(prior["selected"]["thinking_level"], "high") with self.assertRaises(selector.SelectorInputError) as ctx: selector.select_execution_target( @@ -1425,8 +1428,8 @@ class SelectorFailoverContractTests(unittest.TestCase): self.assertEqual( night_failover["used_candidates"], [ - {"adapter": "agy", "target": "Gemini 3.6 Flash (Medium)"}, - {"adapter": "pi", "target": "iop/laguna-s:2.1"}, + {"adapter": "agy", "target": "Gemini 3.6 Flash (High)"}, + {"adapter": "pi", "target": "iop/glm-5.2", "thinking_level": "high"}, ], ) @@ -1444,9 +1447,9 @@ class SelectorFailoverContractTests(unittest.TestCase): def test_runtime_probed_cloud_alternate_round_trips_selected_snapshot(self): snapshot = go_quota_snapshot( "agy", - "Gemini 3.6 Flash (Medium)", + "Gemini 3.6 Flash (High)", "available", - snapshot_id="night-gemini-available", + snapshot_id="gemini-high-available", ) completed = mock.Mock(returncode=0, stdout=json.dumps(snapshot)) with TemporaryDirectory() as tmp, mock.patch( @@ -1454,9 +1457,9 @@ class SelectorFailoverContractTests(unittest.TestCase): ) as run_mock: task_file = write_task_file(Path(tmp), "PLAN", "local", 8) prior = selector.select_execution_target( - task_file, evaluated_at=kst(1) + task_file, evaluated_at=kst(1), quota_snapshot=snapshot ) - self.assertEqual(prior["selected"]["adapter"], "pi") + self.assertEqual(prior["selected"]["adapter"], "agy") result = selector.select_execution_target( task_file, evaluated_at=kst(1), @@ -1465,29 +1468,21 @@ class SelectorFailoverContractTests(unittest.TestCase): failure_class="provider-stream-disconnect", ) - self.assertEqual(result["selected"]["adapter"], "agy") - selected_candidate = next( - candidate - for candidate in result["candidates"] - if candidate["adapter"] == "agy" - ) - self.assertEqual(selected_candidate["quota_status"], "available") - self.assertEqual(result["quota"]["status"], "available") - self.assertEqual( - result["quota"]["snapshot_id"], snapshot["snapshot_id"] - ) - self.assertEqual(result["quota"]["targets"], snapshot["targets"]) - self.assertEqual(run_mock.call_count, 2) + self.assertEqual(result["selected"]["adapter"], "pi") + self.assertEqual(result["selected"]["target"], "iop/glm-5.2") + self.assertEqual(result["selected"]["thinking_level"], "high") + self.assertEqual(result["quota"]["status"], "not_applicable") + self.assertEqual(run_mock.call_count, 1) def test_policy_owned_cloud_promotion_chain_and_no_bounce(self): with TemporaryDirectory() as tmp: - task_file = write_task_file(Path(tmp), "PLAN", "cloud", 5) + task_file = write_task_file(Path(tmp), "PLAN", "cloud", 7) initial = selector.select_execution_target( task_file, evaluated_at=kst(12), quota_probe_command="missing-probe", ) - claude = selector.select_execution_target( + terra = selector.select_execution_target( task_file, evaluated_at=kst(12), transition="promotion", @@ -1497,23 +1492,15 @@ class SelectorFailoverContractTests(unittest.TestCase): resumed = selector.select_execution_target( task_file, evaluated_at=kst(23), - transition="resume", - prior_decision=claude, - ) - terra = selector.select_execution_target( - task_file, - evaluated_at=kst(23), - transition="promotion", - prior_decision=resumed, - failure_class="context-limit", + transition="resume", prior_decision=terra, ) self.assertEqual( - (claude["selected"]["adapter"], claude["selected"]["target"]), + (initial["selected"]["adapter"], initial["selected"]["target"]), ("claude", "claude-opus-4-8"), ) - self.assertEqual(claude["transition"]["kind"], "promotion") - self.assertEqual(claude["transition"]["trigger"], "provider-quota") + self.assertEqual(terra["transition"]["kind"], "promotion") + self.assertEqual(terra["transition"]["trigger"], "provider-quota") self.assertEqual( (terra["selected"]["adapter"], terra["selected"]["target"]), ("codex", "gpt-5.6-terra"), @@ -1521,10 +1508,6 @@ class SelectorFailoverContractTests(unittest.TestCase): self.assertEqual( terra["promotion_path"], [ - { - "adapter": "agy", - "target": "Gemini 3.6 Flash (High)", - }, {"adapter": "claude", "target": "claude-opus-4-8"}, {"adapter": "codex", "target": "gpt-5.6-terra"}, ], @@ -1534,7 +1517,7 @@ class SelectorFailoverContractTests(unittest.TestCase): task_file, evaluated_at=kst(23), transition="promotion", - prior_decision=terra, + prior_decision=resumed, failure_class="provider-quota", ) self.assertEqual(exhausted.exception.code, "no_promotion_target")