skip to content

In Koog, what happens when two edges from the same node both match?

level: middleimportance: should knowfreq 40%

answer

  1. Order is logic, not cosmetics
  2. Think when branches, or catch clauses
  3. Broad first means narrow never fires
  4. Compiles fine, silently unreachable
  5. Last edge should be the total one

basics

~20 s

Koog takes the first matching edge in declaration order and ignores the rest. A broad or unconditional edge declared early therefore shadows every more specific edge below it, which silently makes those branches dead code.

solid answer

~50 s

Outgoing edges in a Koog strategy are evaluated in the order you declared them, and the first whose condition holds is the one taken — there is no priority score and no "most specific wins" rule. That means declaration order is real logic, not cosmetics. If you write a catch-all such as `onCondition { true }` or a bare unconditional `edge(a forwardTo b)` before a narrower `onToolCall { it.tool == "delete" }`, the narrow edge can never fire and the compiler will not complain: it is a perfectly legal, perfectly unreachable edge. The rule of thumb is to declare specific conditions first and catch-alls last, exactly as you would order `when` branches or exception `catch` clauses. And the mirror-image bug is worse: if *no* outgoing edge matches, the run cannot advance and burns iterations until it aborts.

code

kotlin · 12 lines
kotlin
val router = strategy("router") {
    val callLLM by nodeLLMRequest()
    val approve by node<Message.Response, String>("approve") { it.content }
    val handle by node<Message.Response, String>("handle") { it.content }

    edge(nodeStart forwardTo callLLM)
    // catch-all declared first: the edge below it can never fire
    edge(callLLM forwardTo handle onCondition { true })
    edge(callLLM forwardTo approve onCondition { it.content.contains("delete") })
    edge(handle forwardTo nodeFinish)
    edge(approve forwardTo nodeFinish)
}

go deeper

for a junior

Remember that outgoing edges are checked in the order you wrote them and the first match is taken, so put narrow conditions above broad ones.

for a middle

Explain the shadowing bug concretely: a catch-all declared first makes every edge below it unreachable, and because the graph still type-checks nothing warns you about it.

for a senior

Cover both directions — a shadowed safety branch that silently never runs, and a node whose conditions are all narrow so the run strands and dies on maxIterations — and say how you test for each.

for a principal

Own the review standard: edge order is logic, so reordering is a behavioural diff, and any branch that exists for safety or approval needs a test asserting it fires rather than trusting the graph's shape.

## First match wins Inside `strategy("name") { }` you attach outgoing edges to a node one statement at a time. At runtime, when execution leaves a node, Koog walks that node's outgoing edges in the order they were declared, evaluates each condition against the node's output, and takes the first one that holds. Everything after it is skipped for that pass. There is no ranking of specificity, no weighting, no exhaustiveness check. The mental model to use is a Kotlin `when` with non-exclusive branches, or a chain of `catch` clauses: order is semantics. ## The shadowing bug The consequence is the bug worth being able to name in an interview. Suppose you want most model responses to go to a generic handler, but responses containing a destructive tool call to go through an approval node. If you declare the generic edge first, the approval edge below it is unreachable — every response matches the broad condition and leaves via it. The approval node never runs. The reason this survives review is that the code *compiles*. Koog's type checking proves each hop is legal; it says nothing about whether a condition can ever be reached. Nothing is red in the IDE, no warning fires, and in testing the agent behaves plausibly — it just quietly never takes the branch you added it for. On a graph with a safety branch, that is a security-relevant defect, not a style nit. Unconditional edges are the sharpest version. `edge(a forwardTo b)` with no condition matches everything by definition, so it must be the last edge declared on that node; anything after it is dead. ## The mirror-image bug Order shadowing has a twin: no edge matching at all. If a node's outgoing conditions are all narrow and the node produces a value none of them accept, the run reaches that node and cannot leave. The graph makes no progress, and the agent keeps iterating until `maxIterations` aborts the run. When you write conditional edges, always ask what the node emits that none of them cover — and, if the answer is "something", add a last, deliberately unconditional edge to a fallback or error node. So the two rules pull in opposite directions and together give you the shape: 1. Specific conditions first, broad ones last. 2. Ensure the last one really is total, so nothing gets stranded. ## Reviewing for it A practical review technique for a Koog strategy is to read each node's outgoing edges top to bottom and ask, for each one, "what does the edge above already swallow?" If the answer is "everything", the edge you are reading is dead. It is the same question you would ask of a routing table or a firewall rule set, and it is worth doing explicitly because no tool in the pipeline asks it for you. The corresponding test is cheap: exercise the strategy with an input that should take the special branch and assert that the branch's node ran. Because Koog strategies are ordinary Kotlin objects, that is a normal unit test rather than an end-to-end model call, and it is the only thing that actually pins the ordering down against a later edit that inserts an edge in the wrong place. ## Why declaration order rather than specificity It is a deliberate simplicity trade. A specificity-ranked system has to define what "more specific" means across arbitrary user lambdas, which is not decidable in general — Koog's conditions are Kotlin predicates, not a declarative pattern language, so there is nothing to rank. Making order explicit keeps the semantics readable in one pass down the block, at the price of making a re-ordering edit a behavioural change. Treat moving an `edge(...)` line the same way you would treat moving a `catch` clause: it is a logic change, and it belongs in the diff description.

  • Will Koog warn you about an unreachable edge?
    No. Type checking proves each hop is legal, not that a condition is satisfiable, so a shadowed edge compiles clean with no warning and the agent behaves plausibly in testing. The only defence is reading a node's outgoing edges in order and unit-testing that the special branch actually runs — strategies are plain Kotlin objects, so that test is cheap.
  • What is the opposite failure, when no outgoing edge matches?
    The run reaches the node and cannot leave it. The graph makes no progress and the agent burns iterations until maxIterations aborts the run. The guard is to make the last declared edge on any branching node genuinely unconditional, pointing at a fallback or error node, so every value the node can emit has somewhere to go.
  • How should this change the way you review a diff that reorders edges?
    Treat moving an edge line the way you would treat moving a catch clause: it is a behavioural change, not formatting. Reordering can make a previously live branch dead or vice versa, with no compiler signal, so it deserves an explicit note in the diff description and a test that asserts which node handled the case.

saying these in an interview costs you the question

  • Thinks the most specific matching edge wins
  • Expects the compiler to flag an unreachable edge
  • Believes all matching edges fire and fan out
  • Puts an unconditional edge first out of habit
  • Assumes a node with no matching edge just ends the run

context