Tool invoke implementation - #3787
Conversation
✅ Deploy Preview for golemcloud canceled.
|
Amp-Thread-ID: https://ampcode.com/threads/T-01a05674-075d-72eb-888b-20e8037e82b1 Co-authored-by: Amp <amp@ampcode.com> # Conflicts: # golem-worker-executor-test-utils/src/lib.rs # golem-worker-executor/src/durable_host/concurrent/call.rs # golem-worker-executor/src/durable_host/concurrent/mod.rs # golem-worker-executor/src/durable_host/p3/http/response_body.rs # sdks/moonbit/golem_sdk/tool/pkg.generated.mbti # sdks/moonbit/golem_sdk_example1/golem.yaml # sdks/moonbit/golem_sdk_tools/lib/tools_emit.mbt # sdks/scala/codegen/src/main/scala/golem/codegen/rpc/ToolRpcCodegen.scala # sdks/scala/codegen/src/test/scala/golem/codegen/pipeline/CodegenPipelineSpec.scala # sdks/scala/core/js/src/main/scala/golem/runtime/tool/ToolImplementationRuntime.scala # sdks/scala/model/src/main/scala/golem/tool/ToolClient.scala # sdks/scala/model/src/main/scala/golem/tool/ToolInvoke.scala # sdks/scala/model/src/main/scala/golem/tool/ToolStreams.scala # sdks/scala/model/src/test/scala/golem/tool/ToolClientSpec.scala
Amp-Thread-ID: https://ampcode.com/threads/T-01a05674-075d-72eb-888b-20e8037e82b1 Co-authored-by: Amp <amp@ampcode.com> # Conflicts: # sdks/moonbit/golem_sdk/gen/interface/golem/tool/guest/ffi.mbt # sdks/moonbit/golem_sdk/gen/interface/golem/tool/guest/top.mbt # sdks/moonbit/golem_sdk/interface/golem/tool/host/ffi.mbt # sdks/moonbit/golem_sdk/interface/golem/tool/host/pkg.generated.mbti # sdks/moonbit/golem_sdk/interface/golem/tool/host/top.mbt # sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt # sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt # sdks/moonbit/golem_sdk_tools/lib/tools_emit.mbt # sdks/moonbit/golem_sdk_tools/lib/tools_emit_test.mbt
| .clone(); | ||
| let remaining = dst.remaining(store.as_context_mut()); | ||
| if finish { | ||
| attachment.consumer_cancel(); |
There was a problem hiding this comment.
finish = truemeans the read was cancelled, not that the stream closedThe fork documents
StreamResult::Cancelledas "the operation was interrupted … and the producer may be able to produce more items" (futures_and_streams.rs:588-595), and retains the producer afterwards (:2414). The flag comes only from a gueststream.cancel-read(:4172) — reader drop is a separate path, already handled byDrop for AttachmentConsumer.Here it's treated as terminal:
consumer_cancel()clears the buffer and selects the immutableConsumerCancelledterminal, andfinished = truemakes every later poll returnDropped. So one cancelled read silently discards buffered stdout and kills the attachment for good.Same at
:1087; the stdin side (tool/mod.rs:226,:305) has a worse variant, noted separately.
| } | ||
|
|
||
| if finish { | ||
| self.items.take(); |
There was a problem hiding this comment.
Same
finish = truemisreading on stdin — here it becomes silent truncationDropping the sender ends the pump's
recv()loop (:349), which falls through toproducer.finish()(:364) —ByteStreamCloseCause::Finished, a clean EOF. So a guest that cancels one pendingstream.writeinto its stdin source hands the tool a truncated input that looks like normal end-of-stream, and the invocation completes and records a success terminal.Same at
:305inUnderlyingToolStdinStreamConsumer. See the note onattachment.rs:979for whyCancelledisn't a close.
| ) | ||
| .await; | ||
| } | ||
| let host_resource_exhausted = stdin |
There was a problem hiding this comment.
Replay can re-derive stdin exhaustion and hard-diverge from the recorded terminal
The preflight at
:2410only short-circuits when the recorded terminal isSkipped. A call recorded asExecutedfalls through to here — andhost_resource_exhaustedtraces back toActiveAgents::try_acquire, which is global node headroom after eviction, not a function of the invocation's inputs.So recovering a previously-successful capable call on a loaded node synthesizes a
Skipped/ResourceExhaustedterminal, which then fails the equality check incomplete_without_body_access(entity.rs:571) withunexpected_oplog_entry— non-retriable. Co-tenant memory pressure at recovery time permanently breaks recovery of already-durable work.Tests can't catch it:
ActiveAgents::admissionisNoneunder test, sotry_acquirealways grants.Suggestion: skip this branch when
scope().mode() == ReplayingCompleted.
| "tool invocation was fenced before pre-dispatch cancellation" | ||
| )); | ||
| } | ||
| return cancel_registered_tool_before_body( |
There was a problem hiding this comment.
Pre-dispatch cancellation runs before the attachments are configured, so publication no-ops
This returns at
:2517, above thematch filesystemblock at:2527that callsconfigure_live()/configure_completion()— so both attachments are stillAttachmentMode::Pending.cancel_registered_tool_before_bodythen doescancel()+publish_completion()(:2208), butpublish_completionbails unless the mode is alreadyCompletion { published: false }(attachment.rs:264).Net effect: the terminal is selected but never made visible, so the caller's
stream<byte-stream-item>for stdout never yields an item and never closes —poll_produceseeschunks_visible = false,terminal_visible = falseand parks. Trigger:async-invoke-and-awaitwith stdout against an incapable tool, cancelled before dispatch.
SkippedToolAttachmentEndpoints::publish_failure(:1968) has the right shape —configure_completion()first, then fail, then publish.The existing cancellation coverage reads a stdout chunk before cancelling (
rust-caller:687), so it only exercises the post-dispatch path where the mode is alreadyLive.
| (OwnerRuntime::Agent, None) => golem_common::model::card::EffectiveSurface::default(), | ||
| (OwnerRuntime::Entity(_), _) => configured_agent_effective_surface, | ||
| }; | ||
| let local_live_tail = matches!(runtime, OwnerRuntime::Entity(_)) && replay_state.is_live(); |
There was a problem hiding this comment.
local_live_tailis seeded from the cursor's position, not from this store's execution mode
replay_state.is_live()here is the positional check (last_replayed_index == replay_target), and the flag is a one-way latch — 11store(true)sites, nostore(false). So an entity Store constructed while the shared cursor is transiently clamped (e.g. aNonPrimaryrepair callingCursorTx::switch_to_live) latchestrueeven when its ownscope.mode()isReplayingCompleted, andis_live()(:10379) then returns true for the rest of its life.Such a body takes live paths everywhere, never claims its recorded descendants, and re-appends them at the oplog tail under the old parent Start.
The mode is known exactly at construction — is there a reason not to seed from
scope.mode()(live ⇒ true, either replay mode ⇒ false) rather than from the shared cursor?
| invoke-and-await: async func( | ||
| command-path: list<string>, | ||
| input: typed-schema-value, | ||
| stdin: option<stream<u8>>, | ||
| stdin: option<own<tool-stdin>>, |
There was a problem hiding this comment.
A manually created, unfinished stdin deadlocks a synchronous
invoke-and-awaitThe host stages capable stdin to its terminal before admitting the body, so if a caller passes a
tool-stdinfromcreate-stdinand intends to write after the call, it blocks: the host waits for the stdin terminal while the guest is blocked inside this function, and there's no cancellation path for the synchronous mode.The SDKs avoid it by always routing through
create-stdin-from-stream, but the raw contract permits the shape. Worth saying so here, next tocreate-stdin.
| let chunk = source.read(65536) catch { | ||
| error => { | ||
| let failure : Result[FixedArray[Byte], @toolHost.ByteStreamFailure] = Err( | ||
| @toolHost.ByteStreamFailure::Failed(repr(error)), |
There was a problem hiding this comment.
Every source failure is re-encoded as
failed, losing the variant
Cancelled,AbandonedandResourceExhaustedall becomeByteStreamFailure::Failed(repr(error)), so the caller can't distinguish a cancelled source from an exhausted one. This is the only place across the four SDKs where abyte-stream-failureidentity is dropped — Rust, TS (isRpcError) and Scala (both directions) all map the variants exhaustively.
| } | ||
|
|
||
| ///| | ||
| pub fn ProviderStdout::output(self : ProviderStdout) -> @async-core.Sink[Byte] { |
There was a problem hiding this comment.
A MoonBit tool can't fail its stdout
ProviderStdoutexposesoutput(:89),finish(:94) anddrop(:113), but nothing wrappingtool-stdout-writer.fail. So a MoonBit tool can only ever selectfinished— orabandonedby dropping — and can never reportfailed(msg),resource-exhaustedorcancelledon its output, even though the WIT and the host both support it. Rust exposes this asOutputStream::fail(tool_impl.rs:80-86).
| buf.write_string( | ||
| " let " + | ||
| arg.source + | ||
| " = match __golem_stdin {\n Some(__golem_stream) => __golem_stream\n None => return Err(@toolCommon.ToolError::InvalidInput(\"required stdin stream is missing\"))\n }\n", |
There was a problem hiding this comment.
These emitted error returns skip the resource-release helper
The
None => return Err(...)emitted here (and at:2017for stdout) returns directly, bypassing@tool.reject_decoded_invocation, which every other error path in the generated function calls (see the emissions at:2001and:2044). So canonical input resources are never released and a live__golem_stdinis never dropped. The pre-PR stdin branch did call it.
| } from "@golemcloud/golem-ts-sdk"; | ||
| import { z } from "zod/v4"; | ||
|
|
||
| const streamingTool = toolDefinition("ts-streaming").body((body) => |
There was a problem hiding this comment.
typescript_generated_client_streams_livedoesn't use the generated client
golem.yaml:20-22declaresdependencies.tools: golem-it:tool-streaming-ts-provider/ts-streaming, which is what drives the TS bridge generator. But this re-declares the tool shape by hand (:10-16) and callsclient(streamingTool)at:49, so the invocation goes through a locally-built definition rather than the generated bridge.The Rust counterpart does the opposite —
rust-caller/src/lib.rs:1,14importStreamingClient/CapableStreamingClientfrom the generated crates. So despite the test name,bridge_gen/typescript/tool.rs— which this PR reworks substantially — has no end-to-end coverage.Coverage is also much narrower than the Rust pair: one method, one command, required stdin + required stdout, one mode. No fire-and-forget, cancel, optional-stream combinations, mid-stream failure, or capable tool.
Resolves GOL-35