fix(hermes): route follow-ups with task context
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 201
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 201
This commit is contained in:
parent
7996a4f433
commit
bdd282764f
@ -24,7 +24,7 @@ spec:
|
|||||||
ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code
|
ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code
|
||||||
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
|
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
|
||||||
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
|
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
|
||||||
ai.bstein.dev/config-rev: "20260810-unattended-workers-v2"
|
ai.bstein.dev/config-rev: "20260810-context-aware-routing"
|
||||||
vault.hashicorp.com/agent-inject: "true"
|
vault.hashicorp.com/agent-inject: "true"
|
||||||
vault.hashicorp.com/role: hermes-agent
|
vault.hashicorp.com/role: hermes-agent
|
||||||
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
||||||
|
|||||||
@ -73,6 +73,15 @@ COMPLEX_TERMS = {
|
|||||||
"performance",
|
"performance",
|
||||||
"root cause",
|
"root cause",
|
||||||
}
|
}
|
||||||
|
CONTEXTUAL_FOLLOWUP_PATTERNS = (
|
||||||
|
r"\bcontinue\b",
|
||||||
|
r"\bresume\b",
|
||||||
|
r"\bkeep (?:going|working)\b",
|
||||||
|
r"\bloop through\b",
|
||||||
|
r"\b(?:all|remaining|outstanding)\b.{0,80}\b(?:work|tasks?|items?)\b",
|
||||||
|
r"\bdo (?:it|that|this)\b",
|
||||||
|
r"\bfinish (?:it|that|this|everything|all)\b",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -96,6 +105,50 @@ def _tokens(text: str) -> set[str]:
|
|||||||
return words
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
def _is_contextual_followup(text: str) -> bool:
|
||||||
|
"""Return whether an instruction depends on work described earlier."""
|
||||||
|
lowered = text.lower()
|
||||||
|
return any(
|
||||||
|
re.search(pattern, lowered, re.DOTALL)
|
||||||
|
for pattern in CONTEXTUAL_FOLLOWUP_PATTERNS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_text(message: dict[str, Any]) -> str:
|
||||||
|
"""Flatten the text-bearing parts of one conversation-history message."""
|
||||||
|
content = message.get("content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return ""
|
||||||
|
parts: list[str] = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif isinstance(block, dict):
|
||||||
|
value = block.get("text") or block.get("content")
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts.append(value)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_with_recent_context(
|
||||||
|
text: str, conversation_history: list[dict[str, Any]] | None
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""Resolve a referential follow-up against the latest assistant response."""
|
||||||
|
if not _is_contextual_followup(text):
|
||||||
|
return text, False
|
||||||
|
for message in reversed(conversation_history or []):
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
if str(message.get("role") or "").lower() != "assistant":
|
||||||
|
continue
|
||||||
|
prior = _message_text(message).strip()
|
||||||
|
if prior:
|
||||||
|
return f"{text}\n\nRecent assistant context:\n{prior[-6000:]}", True
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
|
||||||
def heuristic_decision(text: str) -> Decision:
|
def heuristic_decision(text: str) -> Decision:
|
||||||
"""Return a safe, deterministic route when local classification is unavailable."""
|
"""Return a safe, deterministic route when local classification is unavailable."""
|
||||||
tokens = _tokens(text)
|
tokens = _tokens(text)
|
||||||
@ -134,6 +187,14 @@ def heuristic_decision(text: str) -> Decision:
|
|||||||
"heuristic",
|
"heuristic",
|
||||||
"analysis or independent review task",
|
"analysis or independent review task",
|
||||||
)
|
)
|
||||||
|
if _is_contextual_followup(text):
|
||||||
|
return Decision(
|
||||||
|
"implementation",
|
||||||
|
"high",
|
||||||
|
"codex",
|
||||||
|
"heuristic",
|
||||||
|
"continuation of material outstanding work",
|
||||||
|
)
|
||||||
if word_count <= 24:
|
if word_count <= 24:
|
||||||
return Decision(
|
return Decision(
|
||||||
"question",
|
"question",
|
||||||
@ -207,13 +268,32 @@ def jetson_decision(text: str, timeout: float = 1.8) -> Decision | None:
|
|||||||
return _validated_local_effort(value, latency_ms)
|
return _validated_local_effort(value, latency_ms)
|
||||||
|
|
||||||
|
|
||||||
def classify_task(text: str) -> Decision:
|
def classify_task(
|
||||||
|
text: str, conversation_history: list[dict[str, Any]] | None = None
|
||||||
|
) -> Decision:
|
||||||
"""Combine local classification with deterministic safety and quality floors."""
|
"""Combine local classification with deterministic safety and quality floors."""
|
||||||
baseline = heuristic_decision(text)
|
effective_text, used_context = _task_with_recent_context(text, conversation_history)
|
||||||
|
baseline = heuristic_decision(effective_text)
|
||||||
if baseline.effort in {"low", "xhigh"}:
|
if baseline.effort in {"low", "xhigh"}:
|
||||||
|
if used_context:
|
||||||
|
return Decision(
|
||||||
|
baseline.shape,
|
||||||
|
baseline.effort,
|
||||||
|
baseline.provider,
|
||||||
|
"heuristic-context",
|
||||||
|
f"{baseline.reason}; resolved against recent assistant context",
|
||||||
|
)
|
||||||
return baseline
|
return baseline
|
||||||
local = jetson_decision(text)
|
local = jetson_decision(effective_text)
|
||||||
if local is None:
|
if local is None:
|
||||||
|
if used_context:
|
||||||
|
return Decision(
|
||||||
|
baseline.shape,
|
||||||
|
baseline.effort,
|
||||||
|
baseline.provider,
|
||||||
|
"heuristic-context",
|
||||||
|
f"{baseline.reason}; resolved against recent assistant context",
|
||||||
|
)
|
||||||
return baseline
|
return baseline
|
||||||
|
|
||||||
# Deterministic policy owns task shape, provider preference, xhigh, and the
|
# Deterministic policy owns task shape, provider preference, xhigh, and the
|
||||||
@ -224,8 +304,9 @@ def classify_task(text: str) -> Decision:
|
|||||||
baseline.shape,
|
baseline.shape,
|
||||||
effort,
|
effort,
|
||||||
baseline.provider,
|
baseline.provider,
|
||||||
"jetson",
|
"jetson-context" if used_context else "jetson",
|
||||||
"Jetson effort classification with deterministic routing guardrails",
|
"Jetson effort classification with deterministic routing guardrails"
|
||||||
|
+ (" and recent assistant context" if used_context else ""),
|
||||||
local.latency_ms,
|
local.latency_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -456,7 +537,7 @@ def _pre_turn_route(ctx: Any, **kwargs: Any) -> None:
|
|||||||
if provider not in PROVIDERS or effort not in EFFORTS:
|
if provider not in PROVIDERS or effort not in EFFORTS:
|
||||||
policy = {"mode": "auto"}
|
policy = {"mode": "auto"}
|
||||||
_write_policy(policy)
|
_write_policy(policy)
|
||||||
decision = classify_task(text)
|
decision = classify_task(text, kwargs.get("conversation_history"))
|
||||||
plan = select_route(_load_json(ROUTING_PATH), decision)
|
plan = select_route(_load_json(ROUTING_PATH), decision)
|
||||||
else:
|
else:
|
||||||
decision = Decision(
|
decision = Decision(
|
||||||
@ -464,7 +545,7 @@ def _pre_turn_route(ctx: Any, **kwargs: Any) -> None:
|
|||||||
)
|
)
|
||||||
plan = select_route(_load_json(ROUTING_PATH), decision, model)
|
plan = select_route(_load_json(ROUTING_PATH), decision, model)
|
||||||
else:
|
else:
|
||||||
decision = classify_task(text)
|
decision = classify_task(text, kwargs.get("conversation_history"))
|
||||||
plan = select_route(_load_json(ROUTING_PATH), decision)
|
plan = select_route(_load_json(ROUTING_PATH), decision)
|
||||||
_apply_route(ctx, agent, plan)
|
_apply_route(ctx, agent, plan)
|
||||||
_record_plan(policy, plan)
|
_record_plan(policy, plan)
|
||||||
@ -476,7 +557,12 @@ def _pre_turn_route(ctx: Any, **kwargs: Any) -> None:
|
|||||||
f"{plan['effort']} · automatic capacity fallback remains enabled"
|
f"{plan['effort']} · automatic capacity fallback remains enabled"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
source = "Jetson" if plan["classifier"] == "jetson" else "fast fallback"
|
source = {
|
||||||
|
"jetson": "Jetson",
|
||||||
|
"jetson-context": "Jetson + recent context",
|
||||||
|
"heuristic-context": "recent-context policy",
|
||||||
|
"heuristic": "deterministic policy",
|
||||||
|
}.get(str(plan["classifier"]), "deterministic policy")
|
||||||
emit(
|
emit(
|
||||||
f"AUTO target → {plan['provider']}/{plan['model']} · "
|
f"AUTO target → {plan['provider']}/{plan['model']} · "
|
||||||
f"{plan['effort']} ({source}) · automatic capacity fallback enabled"
|
f"{plan['effort']} ({source}) · automatic capacity fallback enabled"
|
||||||
|
|||||||
@ -113,6 +113,62 @@ def test_deterministic_low_and_xhigh_routes_skip_local_latency(monkeypatch):
|
|||||||
assert router.classify_task("Migrate production Vault credentials").effort == "xhigh"
|
assert router.classify_task("Migrate production Vault credentials").effort == "xhigh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_referential_outstanding_work_never_uses_low_route(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
router,
|
||||||
|
"jetson_decision",
|
||||||
|
lambda text: router.Decision(
|
||||||
|
"question", "low", "codex", "jetson", "test", 10
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = router.classify_task(
|
||||||
|
"Look, loop through all of the still outstanding work that you identified. "
|
||||||
|
"Do it to the best of your abilities."
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (decision.shape, decision.effort, decision.provider) == (
|
||||||
|
"implementation",
|
||||||
|
"high",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_referential_followup_uses_recent_context_for_risk_floor(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
router,
|
||||||
|
"jetson_decision",
|
||||||
|
lambda text: (_ for _ in ()).throw(AssertionError("xhigh skips Jetson")),
|
||||||
|
)
|
||||||
|
history = [
|
||||||
|
{"role": "user", "content": "Is the work complete?"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": (
|
||||||
|
"Still outstanding: production deployment retry, provider "
|
||||||
|
"switching, runtime experiments, and a full repository test pass."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
decision = router.classify_task(
|
||||||
|
"Loop through all outstanding work and finish it.", history
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (decision.shape, decision.effort, decision.provider) == (
|
||||||
|
"review",
|
||||||
|
"xhigh",
|
||||||
|
"claude",
|
||||||
|
)
|
||||||
|
assert decision.classifier == "heuristic-context"
|
||||||
|
assert "recent assistant context" in decision.reason
|
||||||
|
|
||||||
|
|
||||||
def test_manual_policy_is_reapplied_on_every_non_command_turn(monkeypatch):
|
def test_manual_policy_is_reapplied_on_every_non_command_turn(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
router,
|
router,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user