slack: the agent turn crosses the plugin boundary over ZAP/UDS
@hanzo answered every message with "the agent hit an error handling that". Cause, from the log: `cloud: app is not deployed here: agents`. bridgeReply called agents.RunOnBehalf DIRECTLY, and that function gates on the agents package's `mounted` global. A PLUGIN IS A PROCESS: a package global is per-process, so `mounted` is nil on the bridge's side unless agents happens to be in the same binary. The in-process seam was the ONLY door, which made co-residency an undeclared requirement — and integrations and agents are separate plugins in every real deployment, so every chat bridge failed identically, for Slack, Discord, Teams and Telegram alike. The turn now travels as a typed plane op over ZAP/UDS: plane.AgentsRunOnBehalf + RunOnBehalfIn/RunOnBehalfOut (plane/plane.go) POST /agents/run-on-behalf (apps/agents/onbehalf_rpc.go) plane.Ask from the bridge (apps/integrations/bridge.go) Both doors run the SAME runOnBehalf, so org isolation, linked-subject attribution and billing are identical whichever way the call arrived — sessions_rpc.go's "two doors, one teardown" shape, applied to the run. The bridge no longer imports apps/agents at all: reaching into another plugin's package WAS the coupling, and the dropped import is the proof it is gone. The plane door lives in its own file so onbehalf.go keeps its promise to know nothing of zip.Ctx or the wire. Status travels explicitly rather than being inferred from a non-empty Output — "ran and had nothing to say" and "failed" are different answers and a bridge must not post the second as the first. An empty subject is refused, never defaulted to the org: a turn that lost its caller must not bill the tenant for an unattributable act. ALSO: a DM is answered INLINE. Threading every DM reply buried a one-line answer behind a "1 reply" click; a channel still threads because the reply shares the room, and a DM the user deliberately threaded is honoured. Tests: DM inline, explicit DM thread honoured, channel mention still threads; the existing route test now STATES the inline intent rather than the old behaviour. apps/integrations and plane suites green. apps/agents' TestTargetOpsProjectEverywhere fails identically on unmodified main (op-id naming drift) — verified by stashing tracked AND untracked. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -331,6 +331,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
// The login-manager teardown, for the link process that has no session store
|
||||
// in it — two doors onto the ONE StopSessions (sessions_rpc.go).
|
||||
exposeSessions()
|
||||
exposeRunOnBehalf()
|
||||
|
||||
o := agentOps{s: s}
|
||||
// Bridge FIRST, and at the door this SUBSYSTEM is, not on one node inside it: a
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package agents
|
||||
|
||||
// onbehalf_rpc.go carries an agent turn across a PROCESS boundary, exactly as
|
||||
// sessions_rpc.go carries a teardown. onbehalf.go stays the in-process seam and
|
||||
// keeps its promise to know nothing of zip.Ctx or the wire; this file is the
|
||||
// door, and both run the same runOnBehalf underneath.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// exposeRunOnBehalf publishes the on-behalf-of run on the internal plane, so a
|
||||
// chat bridge in ANOTHER PROCESS can reach it.
|
||||
//
|
||||
// RunOnBehalf above gates on `mounted`, a package global, and a package global
|
||||
// is per-PROCESS. When agents and integrations are separate plugins — which is
|
||||
// the normal deployment, not an exotic one — that global is nil on the bridge's
|
||||
// side and every @hanzo turn died with ErrNoPeer. The in-process seam is not
|
||||
// wrong; it was simply the ONLY door, so co-residency had quietly become a
|
||||
// requirement nothing declared.
|
||||
//
|
||||
// Both doors run the SAME runOnBehalf, so the org isolation, the linked-subject
|
||||
// attribution and the billing that hang off it are identical whichever way the
|
||||
// call arrived.
|
||||
func exposeRunOnBehalf() {
|
||||
zip.Post[plane.RunOnBehalfIn, plane.RunOnBehalfOut](cloud.Plane(), "/agents/run-on-behalf", planeRunOnBehalf,
|
||||
zip.WithOperationID(plane.AgentsRunOnBehalf),
|
||||
zip.WithSummary("Run one agent turn as a linked user, for a chat bridge in another process"))
|
||||
}
|
||||
|
||||
// planeRunOnBehalf answers a bridge's turn.
|
||||
//
|
||||
// Unlike the session ops, the org travels IN the request rather than being taken
|
||||
// from the caller's plane identity: the tenant here is the one that connected the
|
||||
// Slack workspace, resolved by the bridge from the signed team_id, and the bridge
|
||||
// plugin's own identity is not it. That is safe because this op only SPENDS the
|
||||
// named org's own balance under its own agent — it reads nothing across tenants —
|
||||
// and because the subject must be a link the bridge already proved.
|
||||
//
|
||||
// An empty subject is refused rather than defaulted. A turn that lost its caller
|
||||
// must not run AS THE ORG: that would bill the tenant for an unattributable act
|
||||
// and hand an unlinked user the org's agent.
|
||||
func planeRunOnBehalf(ctx context.Context, in *plane.RunOnBehalfIn) (*plane.RunOnBehalfOut, error) {
|
||||
if mounted == nil {
|
||||
return nil, fmt.Errorf("%w: agents", cloud.ErrNoPeer)
|
||||
}
|
||||
if strings.TrimSpace(in.Subject) == "" {
|
||||
return nil, fmt.Errorf("agents: run-on-behalf requires a linked subject")
|
||||
}
|
||||
run, err := runOnBehalf(mounted, ctx, in.Org, in.Subject, in.Ref, in.Input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.RunOnBehalfOut{Status: run.Status, Output: run.Output, RunID: run.ID}, nil
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/agents"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/hanzoai/cloud/apps/kms"
|
||||
)
|
||||
|
||||
@@ -218,9 +218,22 @@ func bridgeReply(s *cloud.Service[state], ctx context.Context, org, provider, ex
|
||||
}
|
||||
return "Connect your Hanzo account to use @hanzo: " + u, true
|
||||
}
|
||||
// IN-PROCESS on-behalf-of run: org (isolation gate + tenant + balance) and the
|
||||
// linked user's Hanzo subject drive billing/attribution. No bearer, no gateway hop.
|
||||
run, rerr := agents.RunOnBehalf(ctx, org, link.Subject, bridgeAgentRef(provider), text)
|
||||
// On-behalf-of run over the PLANE (ZAP/UDS): org is the isolation gate, tenant
|
||||
// and balance; the linked user's Hanzo subject drives attribution. No bearer,
|
||||
// no gateway hop, no public network.
|
||||
//
|
||||
// plane.Ask and NOT agents.RunOnBehalf, which reads that package's `mounted`
|
||||
// global. A plugin is a PROCESS: the global is nil unless agents happens to be
|
||||
// in THIS binary, so the direct call made co-residency an undeclared
|
||||
// requirement and answered ErrNoPeer for every deployment that separates them —
|
||||
// which is every real one. It failed the same way for every chat bridge, so the
|
||||
// door belongs on the plane where the boundary is explicit.
|
||||
out, rerr := plane.Ask[plane.RunOnBehalfIn, plane.RunOnBehalfOut](ctx, "agents", plane.AgentsRunOnBehalf,
|
||||
&plane.RunOnBehalfIn{Org: org, Subject: link.Subject, Ref: bridgeAgentRef(provider), Input: text})
|
||||
run := plane.RunOnBehalfOut{}
|
||||
if out != nil {
|
||||
run = *out
|
||||
}
|
||||
if rerr != nil {
|
||||
s.Log.Warn("bridge: agent run", "provider", provider, "org", org, "err", rerr) // never logs a token
|
||||
return "Sorry — the agent hit an error handling that. Please try again.", false
|
||||
|
||||
@@ -173,10 +173,13 @@ func TestRouteSlackEvent(t *testing.T) {
|
||||
if d.Kind != slackRouteAgent || d.TeamID != "T1" || d.User != "U1" || d.Text != "hello there" || d.ThreadTS != "111.1" {
|
||||
t.Fatalf("app_mention route: %+v", d)
|
||||
}
|
||||
// DM (channel_type=im) → agent.
|
||||
// DM (channel_type=im) → agent, and answered INLINE, not threaded. A channel
|
||||
// reply threads because it shares the room; a DM is already a private
|
||||
// two-party room, so threading buries a one-line answer behind a "1 reply"
|
||||
// click. Slack's own assistants answer inline here.
|
||||
d = routeSlackEvent([]byte(`{"type":"event_callback","team_id":"T1","event_id":"E2","event":{"type":"message","channel_type":"im","user":"U1","text":"hi","channel":"D1","ts":"222.2"}}`))
|
||||
if d.Kind != slackRouteAgent || d.ThreadTS != "222.2" {
|
||||
t.Fatalf("dm route: %+v", d)
|
||||
if d.Kind != slackRouteAgent || d.ThreadTS != "" {
|
||||
t.Fatalf("dm route must be inline: %+v", d)
|
||||
}
|
||||
// Bot's own message → ack (echo-loop guard).
|
||||
if d := routeSlackEvent([]byte(`{"type":"event_callback","team_id":"T1","event_id":"E3","event":{"type":"message","channel_type":"im","bot_id":"B1","text":"echo","channel":"D1","ts":"3.3"}}`)); d.Kind != slackRouteAck {
|
||||
|
||||
@@ -409,9 +409,18 @@ func routeSlackEvent(raw []byte) slackRoute {
|
||||
if ev.BotID != "" || ev.Subtype != "" || ev.User == "" || ev.Text == "" || ev.ChannelType != "im" {
|
||||
return slackRoute{Kind: slackRouteAck}
|
||||
}
|
||||
// A DM does NOT thread. A channel needs threading because the reply shares
|
||||
// the room with everyone else's conversation; a DM is already a private
|
||||
// two-party room, so threading every answer under its own question buries
|
||||
// each one behind a "1 reply" a person has to click. Slack's own assistants
|
||||
// answer inline here, and an agent that makes you open a thread to read one
|
||||
// sentence reads as broken even when it worked.
|
||||
//
|
||||
// A DM the user DELIBERATELY threaded (ev.ThreadTS set) is honoured — that
|
||||
// is them asking for a side conversation, not the default.
|
||||
return slackRoute{
|
||||
Kind: slackRouteAgent, TeamID: env.TeamID, Channel: ev.Channel, User: ev.User,
|
||||
Text: stripLeadingMention(ev.Text), ThreadTS: threadOr(ev.ThreadTS, ev.TS),
|
||||
Text: stripLeadingMention(ev.Text), ThreadTS: ev.ThreadTS,
|
||||
}
|
||||
default:
|
||||
return slackRoute{Kind: slackRouteAck}
|
||||
|
||||
@@ -40,3 +40,32 @@ func TestHomeViewShape(t *testing.T) {
|
||||
t.Fatal("view surface must be home")
|
||||
}
|
||||
}
|
||||
|
||||
// A DM must answer INLINE. Threading every DM reply buries a one-line answer
|
||||
// behind a "1 reply" click, which reads as broken even when the run succeeded.
|
||||
func TestDMReplyIsNotThreaded(t *testing.T) {
|
||||
raw := []byte(`{"type":"event_callback","team_id":"T1","event":{"type":"message","channel_type":"im","channel":"D1","user":"U1","text":"hi","ts":"111.1"}}`)
|
||||
d := routeSlackEvent(raw)
|
||||
if d.Kind != slackRouteAgent {
|
||||
t.Fatalf("a DM must reach the agent, got kind %v", d.Kind)
|
||||
}
|
||||
if d.ThreadTS != "" {
|
||||
t.Errorf("a DM reply must be inline, got ThreadTS=%q", d.ThreadTS)
|
||||
}
|
||||
}
|
||||
|
||||
// ...but a DM the user deliberately threaded stays in that thread.
|
||||
func TestExplicitDMThreadIsHonoured(t *testing.T) {
|
||||
raw := []byte(`{"type":"event_callback","team_id":"T1","event":{"type":"message","channel_type":"im","channel":"D1","user":"U1","text":"hi","ts":"222.2","thread_ts":"111.1"}}`)
|
||||
if d := routeSlackEvent(raw); d.ThreadTS != "111.1" {
|
||||
t.Errorf("an explicit thread must be honoured, got %q", d.ThreadTS)
|
||||
}
|
||||
}
|
||||
|
||||
// A channel @mention still threads — the reply shares the room.
|
||||
func TestChannelMentionStillThreads(t *testing.T) {
|
||||
raw := []byte(`{"type":"event_callback","team_id":"T1","event":{"type":"app_mention","channel":"C1","user":"U1","text":"<@B1> hi","ts":"333.3"}}`)
|
||||
if d := routeSlackEvent(raw); d.ThreadTS != "333.3" {
|
||||
t.Errorf("a channel mention must thread under itself, got %q", d.ThreadTS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,11 @@ const (
|
||||
// side, which is what keeps a revoke bounded to its own user's sessions.
|
||||
AgentsSessionsStop = "agents_sessions_stop"
|
||||
AgentsSessionsCount = "agents_sessions_count"
|
||||
// AgentsRunOnBehalf is the chat bridges' door onto a run. A plugin is a
|
||||
// PROCESS, so agents.RunOnBehalf — which gates on that package's `mounted`
|
||||
// global — can only ever answer when agents happens to be co-resident. It was
|
||||
// not, and every @hanzo turn in Slack died on ErrNoPeer.
|
||||
AgentsRunOnBehalf = "agents_run_on_behalf"
|
||||
|
||||
// The x402 rail, across the process boundary. Four ops, because the four
|
||||
// things a settlement needs live in four binaries: the RAIL is x402's, the
|
||||
@@ -1542,3 +1547,37 @@ type Ownership struct {
|
||||
// Other is true when some org OTHER than the caller's owns one.
|
||||
Other bool `json:"other"`
|
||||
}
|
||||
|
||||
// RunOnBehalfIn asks agents to run one turn AS a linked user.
|
||||
//
|
||||
// The bridges (Slack, Discord, Teams, Telegram) are their own plugin, so this
|
||||
// crosses a process boundary and must be a typed plane op rather than a Go call:
|
||||
// a package global cannot be reached from another process, and the in-process
|
||||
// shortcut answered ErrNoPeer for every deployment that did not happen to place
|
||||
// agents and integrations in one binary.
|
||||
//
|
||||
// No field here is a map — the encoder refuses one at the plane boundary.
|
||||
type RunOnBehalfIn struct {
|
||||
// Org is the isolation gate, the tenant, and the balance the run bills.
|
||||
Org string `json:"org"`
|
||||
// Subject is the caller's LINKED Hanzo identity, unqualified. Attribution and
|
||||
// authorization both hang off it, so a turn can never run as nobody: the
|
||||
// answering side refuses an empty subject rather than falling back to the org.
|
||||
Subject string `json:"subject"`
|
||||
// Ref names the agent to run.
|
||||
Ref string `json:"ref"`
|
||||
// Input is the user's message, already stripped of the leading @mention.
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
// RunOnBehalfOut is one finished turn.
|
||||
//
|
||||
// Status is carried EXPLICITLY rather than inferred from a non-empty Output,
|
||||
// because "the agent ran and had nothing to say" and "the agent failed" are
|
||||
// different answers and a bridge must not post the second as the first.
|
||||
type RunOnBehalfOut struct {
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user