Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions go/protocol/types_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 36 additions & 2 deletions go/server/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ type inboundFrame struct {
// get_session / send_message / confirm_tool_action
SessionID string `json:"sessionId"`
Message string `json:"message"`
// send_message — optional multimodal/file attachments. Captured RAW and parsed
// separately (fail-soft) in handleSendMessage: a malformed images/files array is
// dropped without rejecting the turn, mirroring the Rust reference's
// from_value(...).ok().unwrap_or_default(). Folding them into this struct directly
// would make a malformed array fail the whole-frame unmarshal, which is NOT
// fail-soft.
Images json.RawMessage `json:"images"`
Files json.RawMessage `json:"files"`
// confirm_tool_action — *bool so a missing verdict is distinguishable from
// false (fail closed: a missing/garbled approved must NOT silently approve).
Approved *bool `json:"approved"`
Expand Down Expand Up @@ -581,6 +589,28 @@ func (d *FrameDispatcher) handleSendMessage(ctx context.Context, frame inboundFr
// the connection's single active turn before the goroutine starts, so a cancel that
// lands immediately after this frame still finds it.
turnCtx, turnCancel := context.WithCancel(ctx)
// Per-turn host context: the turn's image + file attachments (surfaced to host
// tools) and the directive sink (where a host tool writes a client-side directive
// that lands on the terminal eventual_response). Parsed fail-soft — a malformed
// images/files array is dropped, never rejecting the turn — mirroring the Rust
// reference (handler.rs images parse + tool_provider.rs ToolProviderContext). The
// engine dispatches every tool with turnCtx, so a host tool reads this via
// TurnContextFrom(ctx). Empty attachments + no directive ⇒ behavior unchanged.
turnState := &TurnContext{}
// Drop a malformed array wholesale (Go's Unmarshal partially populates a slice on a
// bad element, so discard the partial result) — matching the Rust reference's
// from_value(...).ok().unwrap_or_default() all-or-nothing fail-soft.
if len(frame.Images) > 0 {
if err := json.Unmarshal(frame.Images, &turnState.Images); err != nil {
turnState.Images = nil
}
}
if len(frame.Files) > 0 {
if err := json.Unmarshal(frame.Files, &turnState.Files); err != nil {
turnState.Files = nil
}
}
turnCtx = withTurnContext(turnCtx, turnState)
turn := &activeTurn{requestID: requestID, cancel: turnCancel}
d.turnMu.Lock()
d.current = turn
Expand Down Expand Up @@ -639,8 +669,12 @@ func (d *FrameDispatcher) handleSendMessage(ctx context.Context, frame inboundFr
d.offerOtp(ctx, session.SessionID, tool, contact, requestID, sink)
}
}
// 3. Terminal eventual_response.
sink(eventualResponse(requestID, 200, result.MessageID, generalResponse(result.Reply), false, result.Citations))
// 3. Terminal eventual_response. Drain the turn's directive sink (a host tool may
// have written a send_file / navigation directive); absent ⇒ the field is
// omitted (back-compat). Mirrors the Rust runner draining directive_sink onto
// eventual_response (runner.rs / protocol.rs).
directive, hasDirective := turnState.Directive()
sink(eventualResponse(requestID, 200, result.MessageID, generalResponse(result.Reply), false, result.Citations, directive, hasDirective))
}()
}

Expand Down
11 changes: 10 additions & 1 deletion go/server/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ func streamChunk(requestID, node string, state map[string]any) map[string]any {
// eventualResponse is the terminal turn event. Matches the Rust/C# shape: a
// triple-nested data.data carrying messageId, the agent response, needsEscalation,
// and (only when non-empty) the citations array.
func eventualResponse(requestID string, status int, messageID string, response map[string]any, needsEscalation bool, citations []Citation) map[string]any {
//
// directive is an opaque host-written client-side directive drained from the turn's
// TurnContext (the send_file convention et al). It is emitted under data.data.directive
// only when a host tool wrote one this turn — passing (nil, false) omits the field,
// keeping the wire byte-for-byte unchanged for the common no-directive turn. Mirrors the
// Rust protocol::eventual_response `directive: Option<Value>` (protocol.rs).
func eventualResponse(requestID string, status int, messageID string, response map[string]any, needsEscalation bool, citations []Citation, directive any, hasDirective bool) map[string]any {
inner := map[string]any{
"messageId": messageID,
"response": response,
Expand All @@ -104,6 +110,9 @@ func eventualResponse(requestID string, status int, messageID string, response m
}
inner["citations"] = arr
}
if hasDirective {
inner["directive"] = directive
}
return map[string]any{
"type": "eventual_response",
"requestId": requestID,
Expand Down
6 changes: 3 additions & 3 deletions go/server/protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ func TestEmittedEventsValidateAgainstSpec(t *testing.T) {
{
name: "eventual_response",
schemaRef: "events/eventual-response.schema.json",
event: eventualResponse("req-2", 200, "m1", generalResponse("hi there"), false, nil),
event: eventualResponse("req-2", 200, "m1", generalResponse("hi there"), false, nil, nil, false),
},
{
name: "eventual_response_with_citations",
schemaRef: "events/eventual-response.schema.json",
event: eventualResponse("req-3", 200, "m2", generalResponse("returns are 17 days"), false, []Citation{
{ID: "doc-1", Title: "policies/returns.md", URL: "https://example.com/returns.md", Snippet: "17 days", Score: 0.9},
}),
}, nil, false),
},
{
name: "error",
Expand Down Expand Up @@ -144,7 +144,7 @@ func TestEmittedEventsRoundTripIntoClientTypes(t *testing.T) {
}

t.Run("eventual_response", func(t *testing.T) {
frame := marshal(t, eventualResponse("req-1", 200, "msg-123", generalResponse("hi"), false, nil))
frame := marshal(t, eventualResponse("req-1", 200, "msg-123", generalResponse("hi"), false, nil, nil, false))
ev, err := protocol.ParseServerEvent(frame)
if err != nil {
t.Fatalf("parse: %v", err)
Expand Down
74 changes: 74 additions & 0 deletions go/server/turn_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package server

import (
"context"
"sync"

"github.com/SmooAI/smooth-operator/go/protocol"
)

// TurnContext is the per-turn host context a tool sees during a send_message turn —
// the Go analog of the Rust ToolProviderContext's file-transfer fields
// (rust/smooth-operator/src/tool_provider.rs). It carries the turn's image and file
// attachments (so a host tool can read them) and a directive sink (where a host tool
// writes a client-side directive that lands on the turn's eventual_response). A turn
// with no attachments and no directive written leaves behavior byte-for-byte unchanged.
//
// The Go engine core dispatches every tool with the turn's context.Context (see
// SmoothAgent.dispatchTool → Tool.Execute), so the server attaches this onto the turn's
// context and a host tool retrieves it with TurnContextFrom(ctx).
type TurnContext struct {
// Images the turn carried (multimodal turns). A host tool may read them; empty for
// the text-only common case.
//
// NOTE (parity gap, documented): the Rust reference ALSO attaches images to the
// engine's user message as OpenAI image_url content parts (via core
// `with_user_images`). The pinned Go engine core
// (smooth-operator-core/go) has no multimodal ChatMessage content or user-images
// option — `ChatMessage.Content` is a plain string that openai.go serializes as a
// string — so images cannot reach the model until that core ships multimodal
// support and the pin is bumped. Until then images are surfaced to host tools only.
Images []protocol.RequestImagesElem
// Files the turn carried. NEVER sent to the model — a host tool reads these to
// persist them into the agent's workspace, where ordinary tools (read_file, bash)
// can then use them.
Files []protocol.RequestFilesElem

mu sync.Mutex
directive any
hasDirective bool
}

// SetDirective records a client-side directive for this turn (last-write-wins). A host
// tool calls TurnContextFrom(ctx).SetDirective(...) during Execute; the value is opaque
// (mirrors the Rust directive sink's serde_json::Value) and is emitted on the terminal
// eventual_response. Safe under ParallelToolCalls (concurrent tool dispatch).
func (t *TurnContext) SetDirective(v any) {
t.mu.Lock()
t.directive = v
t.hasDirective = true
t.mu.Unlock()
}

// Directive returns (value, true) when a host tool wrote a directive this turn, or
// (nil, false) when none was written — in which case eventual_response omits the
// `directive` field (back-compat), mirroring the Rust drain's Null check.
func (t *TurnContext) Directive() (any, bool) {
t.mu.Lock()
defer t.mu.Unlock()
return t.directive, t.hasDirective
}

type turnContextKey struct{}

// withTurnContext attaches tc to ctx so tools dispatched on this ctx can read it.
func withTurnContext(ctx context.Context, tc *TurnContext) context.Context {
return context.WithValue(ctx, turnContextKey{}, tc)
}

// TurnContextFrom returns the per-turn TurnContext a host tool may read, or nil when
// the turn carried none (e.g. any non-send_message code path).
func TurnContextFrom(ctx context.Context) *TurnContext {
tc, _ := ctx.Value(turnContextKey{}).(*TurnContext)
return tc
}
Loading
Loading