Skip to content

Tool definition host functions - #3739

Merged
vigoo merged 4 commits into
mainfrom
tool-host
Aug 14, 2026
Merged

Tool definition host functions#3739
vigoo merged 4 commits into
mainfrom
tool-host

Conversation

@vigoo

@vigoo vigoo commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Resolves GOL-30

@vigoo
vigoo requested a review from a team August 13, 2026 16:47
@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for golemcloud canceled.

Name Link
🔨 Latest commit 290ed0f
🔍 Latest deploy log https://app.netlify.com/projects/golemcloud/deploys/6a7f15c1c905c5000864b5f6

@kmatasfp kmatasfp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's one thing we should probably change before merge (comment on durable_host/tool/mod.rs), and two things I wasn't sure about and would rather ask than assume. All inline.

On tests — the ticket asks for coverage of enumeration, lookup, empty environments, unknown names, access filtering and revision isolation. The first four are there. Revision isolation only really shows up indirectly through the replay test, and nothing sets up a second environment, so the "don't expose tools from unrelated environments" part isn't checked anywhere.

Tiny one: the comment at the top of durable_host/tool/mod.rs (lines 15-17) ends with "until the tool runtime is implemented". AGENTS.md asks that comments say what the code does now rather than what's coming later. The "layered on in later steps" bit was already dropped — this is just the leftover.

}
}
} else {
Ok(Vec::new())

@kmatasfp kmatasfp Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If parsed_agent_id() up at line 89 comes back None, we land here and return an empty list. That empty list doesn't just go back to the caller — it goes through handle.complete(...) and gets written to the oplog, so every replay from then on sees "no tools" as well, and there's no way to tell it apart from an environment that genuinely has none.

GOL-30 says to filter by the calling agent's accessible set. If we can't work out who the caller is, we don't have an accessible set — that's a bug on our side rather than an empty answer, so I think this should return an error. classify_tool_discovery_error and terminal_tool_discovery_error are already set up in this file

Same thing happens at line 197.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an error now (in practice, if it's None it means that an oplog processor plugin called this host function)

}
}
} else {
Ok(None)

@kmatasfp kmatasfp Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as line 129 — when agent_type is None we save an empty answer.

If valid_tool_name is None because someone passed a name that isn't a valid tool name, returning None looks right to me — GOL-30 wants get-tool to answer unknown names with none. It's only the missing agent id that I think should be an error.

let result = self
.state
.environment_state_service
.get_accessible_tool(environment_id, agent_type, valid_tool_name)

@kmatasfp kmatasfp Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of these two functions goes and fetches the environment state on its own, so a get-all-tools and a get-tool in the same invocation can end up looking at different deployments. An agent could list a tool and then fail to find it a moment later.

GOL-30 asks to use the same environment-state snapshot that GOL-29 produces, which reads like it wants one snapshot held for the whole invocation. But the live test here deliberately checks that get-all-tools picks up a new deployment part way through, so I'm guessing seeing changes as they happen is what you actually wanted. Which of the two is it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now works the same as the agent type related host functions - an agent sees the deployment it's originated from, not the latest.

.registered_tools
.get(tool_name)
.cloned()
.ok_or_else(|| ToolDiscoveryError::InconsistentSnapshot {

@kmatasfp kmatasfp Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lookup, and the one at line 114, match a binding to a tool on the name alone. Neither the binding's deployment_revision nor the tool's gets checked against the deployment's.

Worth flagging that the test at line 483 doesn't cover this, even though it reads like it might — deployment_state() builds every tool at revision 1, so asserting they all come back at revision 1 passes regardless of what this function does with revisions. It'd pass just as happily if the join ignored them entirely, which is what it does.

In practice it's fine as long as GOL-29 always hands over a snapshot from a single revision, which I'd expect it to. So this is a question rather than a complaint: is leaving the revision check entirely to the code that builds the snapshot the split you meant? Happy for the answer to be yes — just want it to be a decision rather than an accident.

.get(tool_name)
.cloned()
.map(Some)
.ok_or_else(|| ToolDiscoveryError::InconsistentSnapshot {

@kmatasfp kmatasfp Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing: this error is word for word the same as the one at line 87. Creating a little dangling_binding(agent_type, tool_name) or similar helper would make sure these 2 stay in sync, unless in the future it is ok for them to drift?

)
}

#[test]

@kmatasfp kmatasfp Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of gaps against the coverage the ticket asks for, on access filtering and revision isolation:

Nothing here uses a second environment — every test runs against context.default_environment_id, so we never actually check that a tool in one environment stays hidden from another.

Nothing covers a stale revision either. Revision isolation only shows up indirectly, through the replay test. A case where a binding or a RegisteredTool carries an older deployment_revision than the deployment would also address the comment I raised over on environment_state.rs:87.

@kmatasfp kmatasfp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One follow-up on performance, inline.

deployment
.registered_tools
.get(tool_name)
.cloned()

@kmatasfp kmatasfp Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each call here deep-clones every tool it returns, and the source is already behind an Arc<EnvironmentState> from the cache — so a shared, immutable tool definition gets copied in full, schema graph and all, every time. DiscoveredTool::from then destructures it and drops everything from source bar the component id. There's a second Vec on top of that, allocated in get_all_tools_model for the .map(DiscoveredTool::from).collect().

The same .cloned() is in get_accessible_tool_from_snapshot at line 115. That one only copies a single tool rather than the whole accessible set, so it matters less, but it's the same wasted copy and the same fix.

One agent doing this occasionally is nothing, but with a lot of agents each calling get-all-tools per model turn it could start to add up — and it looks fairly easy to avoid. If both functions built DiscoveredTool directly they'd only need to clone definition and the component id, and the full copies and the second Vec would go away.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@vigoo
vigoo merged commit ea108e8 into main Aug 14, 2026
54 checks passed
@vigoo
vigoo deleted the tool-host branch August 14, 2026 13:51
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 14, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants