Skip to content

Hold update_node on a held turn to the offered types, by the target node's type #3498

Closed

Description

@malibio

Overview

A routed turn whose matched skills all link to their schemas is held to an offered set of types (#3419, ADR-038's 2026-10-02 amendment). The hold reads a call's type argument, so it covers create_node, search_nodes, resolve_query and update_schema. update_node takes a node id and no type, so it is not held: on a held turn it can change a node of any type the model has an id for.

The node's type is knowable. The id resolves to a node, and the node has a type. This issue holds update_node by looking that type up at dispatch.

Problem Statement

  • Tool::existing_type_parameter (packages/agent/src/local_agent/tools.rs) returns None for update_node, with the note "it takes a node id, and the node's type is whatever that node already is". tools::off_menu_type therefore never refuses it.
  • The model can reach an id outside the offered set without a held tool: from search_semantic, from get_node, from get_related_nodes, from the MENTIONED ENTITIES block, or from an earlier turn of the chat.
  • So a turn held to, say, warranty_claim can still rewrite a company or a task through update_node. The original issue listed update_node among the tools to hold; it was left out only because there is no argument to put an enum on.
  • The schema decision record has the same blind spot. decisions::selected_schema reads update_node's arguments, finds no type, and records no selection. The turn loop's selection_held then has to special-case it so the record is not marked enforced.

Proposed Solution

  1. A tool can name the node it acts on. Add Tool::held_node_id_parameter(self) -> Option<&'static str> beside existing_type_parameter, as an exhaustive match so a new tool forces the decision. It returns Some("id") for update_node and None for every other tool, each None for a stated reason (see Non-Goals for the id-based tools left out).
  2. The executor can say what type a node is. Add async fn node_type(&self, id: &str) -> Option<String> to AgentToolExecutor (packages/agent/src/agent_types.rs), defaulting to None. GraphToolExecutor implements it with one read through the node service. None means the node could not be found or read.
  3. Dispatch refuses an update_node on a node outside the set. In the guard chain in agent_loop.rs, next to the existing off-menu branch: on a turn with an offered set, resolve the call's node id, look up its type, and refuse when the type is not in the set.
    • The set already contains the linked types' subtypes, so the test is plain membership.
    • A node that cannot be found (None) is not refused. The call goes to the executor, which reports its own not-found error.
    • Read the id the way the executor will: AgentUpdateNodeParams accepts node_id as an alias of id, and ids arrive as nodespace:// URIs (tools::strip_node_uri). A check that reads only id is bypassed by node_id.
    • No lookup happens on a turn without an offered set.
  4. The refusal names the node's type and the allowed ids. It reuses the type_not_offered error code and allowed_types, with its own message: this node is a <type>, this request covers only <set>, and nothing was changed. It must not tell the model to "re-send with one of them", since a node's type cannot be chosen. It ends with route_clarify when that tool is on the turn's surface and "say so in your reply" when it is not, as the existing refusal does.
  5. The log line and the decision record follow.
    • Tool executed already carries type_refused and off_menu_ran. Both must be right for update_node: type_refused=true on the refusal, and off_menu_ran worked out from the looked-up type and from whether the call was dispatched.
    • On a held turn, a round whose first call is update_node records the node's type as the schema decision's selection, so off_menu and enforced mean the same thing for it as for the other held tools. The selection_held special case then no longer needs to exclude update_node.
  6. No tool-schema change. There is no parameter to put an enum on, so the model learns the limit only from the refusal. The tool definitions stay byte-identical.

Architecture Impact

  • ADR-038 (../nodespace-docs/decisions/038-skill-mediated-tool-routing.md), amendment dated 2026-10-02, "Tools the set does not hold": it says update_node "takes a node id and names no type: the node's type is whatever that node already is". This issue extends that amendment: update_node becomes held, by lookup. Companion docs change required: edit that paragraph and the "At dispatch" bullet to describe the lookup, and add the consequence below.
  • ../nodespace-docs/components/seeded-nodes.md, "Routed turns are held to the linked set": the "In the tool schema" bullet says update_node "names no type". Update it to say it is held at dispatch by the target node's type.
  • ADR-064 (decisions/064-instruction-channel-doctrine.md): consistent. The refusal is a tool result. There is no schema channel available for this constraint, which the ADR amendment should say.
  • ADR-056 (decisions/056-gemma-4-e4b-locked-native-model.md): unaffected.

Consequence to record in the ADR: the check uses the node's own type. A held turn can then no longer update a text child under one of its records (a note in a claim's body), unless a skill links to text. A skill that edits body content links to the content types it edits.

Acceptance Criteria

  • Tool::held_node_id_parameter exists as an exhaustive match; only update_node returns a parameter (unit test over Tool::ALL, and a test that the parameter is declared in the tool's schema)
  • AgentToolExecutor::node_type exists with a None default, and GraphToolExecutor returns the stored type for an existing node and None for a missing one (test)
  • On a held turn, update_node on a node whose type is outside the set is not executed, and the result carries type_not_offered, the allowed ids and the node's type (test asserting the executor was not called)
  • On a held turn, update_node on a node whose type is in the set runs, including a node of a subtype of a linked type (test)
  • The id is read as the executor reads it: id, the node_id alias, and a nodespace:// URI all resolve to the same node (test per form)
  • A node id that resolves to nothing is not refused for its type; the executor's own error is returned (test)
  • On a turn without an offered set (unlinked, mixed, unrouted, fail-open, routing disabled) no lookup is made and update_node behaves as today (test asserting node_type was never called)
  • The full composed refusal message is pinned, with and without route_clarify on the surface, and does not offer a re-send on another type
  • Tool executed logs type_refused=true off_menu_ran=false for the refusal, and off_menu_ran=true when the refusal branch is disabled (mutation-checked, as for the argument-based hold)
  • On a held turn, the schema decision for a round opening with update_node records the node's type as its selection and is enforced
  • The decision fixture gains a scenario: a held turn asked to change a record of a type outside the set, scored as held, nothing off-menu run and the record unchanged
  • eval:decisions on the locked model, before and after, one rep each on a purged database: the twelve unheld scenarios keep their verdicts, and the new scenario passes after. Results posted on this issue
  • ADR-038's amendment and seeded-nodes.md are updated and committed to nodespace-docs
  • bun run test:changed passes; bun run quality:fix clean

Technical Specifications

Reference Files

  • Dispatch guard chain: packages/agent/src/local_agent/agent_loop.rs. The existing branch is the one calling super::tools::off_menu_type; offered_types is computed once per turn above it; off_menu_type_refused_result builds the refusal; dispatched and off_menu_ran feed the Tool executed line; selection_held gates enforced.
  • Per-tool declarations: packages/agent/src/local_agent/tools.rs. Tool::existing_type_parameter, existing_type_parameter_tool, off_menu_type, AgentUpdateNodeParams (the node_id alias), strip_node_uri, exec_update_node.
  • Executor trait: packages/agent/src/agent_types.rs, AgentToolExecutor. task_status_values and user_type_names are the pattern for a narrow, defaulted read method.
  • Offered set: packages/agent/src/local_agent/routing.rs, offered_types.
  • Decision record: packages/agent/src/local_agent/decisions.rs, selected_schema, schema_scoped, DecisionRecord::enforced.
  • Tests to extend: the "Offered types" block in agent_loop.rs's tests (type_naming_tools, billing_candidate, run_scripted_turn, RoutingToolExecutor::executed_handle, turn_log).
  • Eval fixture: scripts/eval/fixtures/decisions.ts (seedLinkedSkills, assertHeldToOfferedTypes, held-off-menu-type), scripts/aichat.ts (the type_refused / off_menu_ran scrape).

Implementation Notes

  • The lookup needs an await inside the guard chain, which is already async. Do it only after the cheaper guards that would stop the call anyway (the route_clarify supersede and the cross-turn duplicate), where the existing off-menu branch sits.
  • off_menu_ran is computed at the log site from the call's arguments today. For update_node it needs the looked-up type. Carry the type resolved at dispatch to the log site instead of looking it up twice.
  • The decision record is written at the top of the round, before dispatch. Recording the node's type there means one lookup for the round's first call, on held turns only.
  • For the eval scenario, the seeded Northwind Trading company is a record outside the linked set that the turn can resolve by name, and the three linked warranty_claim skills already hold the turn. The request has to stay in the linked skills' vocabulary so retrieval still leads with them.
  • Check the fixture's age before the eval run, and that no other eval is using the machine.

Non-Goals

  • The other tools that act on a node by id. update_task_status, delete_node, create_relationship (two ids), get_node, get_related_nodes and the conflict tools stay unheld. Each gets an explicit None arm in held_node_id_parameter saying so. Holding reads would stop a held turn following a relationship to a related record, and delete_node already has its own score bar and confirmation. Widening the hold to other writes is a separate decision.
  • Allowing a node by its ancestry. A text child under an offered record is judged by its own type.
  • Any change to when a turn is held, or to what the offered set contains.
  • A tool-schema or prompt change. Nothing model-facing changes except the refusal's text.

Dependencies

Related Issues

Activity

  1. malibio commented on Oct 3, 2026

    @malibio
    CollaboratorAuthor

    Baseline — Frontend: 309 test files passed, 5606 tests passed

  2. self-assigned this
    on Oct 3, 2026
  3. malibio commented on Oct 3, 2026

    @malibio
    CollaboratorAuthor

    eval:decisions before and after (gemma-4-e4b-q4km, n_ctx 32768, one rep each, purged database, isolated NODESPACE_HOME)

    before (main b1d0e287 daemon) after (this branch)
    pass^1 22/25 23/25
    the 24 existing scenarios 22 pass, 2 fail 22 pass, 2 fail (same verdict on every one)
    held-off-menu-node (new) fail pass
    off-menu calls refused at dispatch 0 1
    off-menu calls that ran 0 reported (the old daemon has no lookup, so it cannot report this one) 0

    Standing failures in both runs: outcome-list-types, skill-instance-request.

    The new scenario. A held turn (three skills linked to warranty_claim) asked to change the signed date on the seeded company_sold_to record:

    • Before: update_node {"field_values":{"filed_date":"2025-05-02"},"id":<Northwind>} ran. It wrote a warranty-claim field onto the company, and the reply said "The signing date for Northwind Trading has been updated to May 2nd, 2025."
    • After: the same call was refused: type_refused=true off_menu_ran=false, result {"error":"type_not_offered","allowed_types":["warranty_claim"],"node_type":"company_sold_to", ...}. The reply: "I was unable to update the date for Northwind Trading because it is a company_sold_to node, and I can only make changes to nodes of type "Warranty Claim"." The schema decision for that round recorded selected: company_sold_to, off_menu: true, enforced: true.

    Two things worth knowing about these runs

    • The scenario needed a prior turn to reach its own question. In three single-turn wordings the held turn searched for a warranty claim or asked what to do, never called update_node, and passed on the baseline daemon too. With an open prior turn that looks the company up, the model sends the call. The fixture comment records this.
    • A first baseline run scored 17/25, with six unheld scenarios failing on turns of 70–95s that "never reached inference". Another session's release build was running on the machine at the time. That run is discarded; the figures above are from a re-run on a quiet machine. The after run and the re-run agree on all 24 existing scenarios.
    • On the refusal the model replied in prose and did not call route_clarify, though the message names it and it was on the surface.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions