From 1c737bb21cbc081efd86105a2bdcf7aac6afa85a Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 6 Jun 2026 00:32:41 +0100 Subject: [PATCH] feat(kb): per-agent hf-token via HostAPI.GetAgentSecret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4c. Each KB tool call now picks the right KBClient based on the calling agent: per-agent hf-token (read via HostAPI.GetAgentSecret) takes priority, plugin-level Config.APIKey is the fallback for cases where the agent has no per-agent token set. Behaviour: - Agent has hf-token in secrets.enc → use that, KB calls run with the agent's own HF role - Agent has no token but plugin-level apiKey is set → fall back - Neither configured → clear "HF KB backend unavailable" error surfaces to the model (no silent 401 chain) internal/tools/kb.go: - KBDeps.AgentClient(ctx, agentID) callback resolves per-agent client; nil result means "no per-agent token, try fallback" - clientForCall picks the right client per tool invocation - List* tools resolve agentID from sdkplugin.AgentIDFromContext - Cache tool already had agentID from the dispatch shim cmd/.../main.go: wires AgentClient closure — host.GetAgentSecret for "hf-token", kbclient.New(BackendURL, token) if non-empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/plexum-harborforge-plugin/main.go | 13 ++++++ internal/tools/kb.go | 58 +++++++++++++++++++++------ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/cmd/plexum-harborforge-plugin/main.go b/cmd/plexum-harborforge-plugin/main.go index 00ccda6..1388ebd 100644 --- a/cmd/plexum-harborforge-plugin/main.go +++ b/cmd/plexum-harborforge-plugin/main.go @@ -207,6 +207,19 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er return "" }, Turn: func(agentID string) int { return 0 }, // best-effort; turn ctx not available to plugins yet + AgentClient: func(ctx context.Context, agentID string) (*kbclient.Client, error) { + if agentID == "" { + return nil, nil + } + token, err := host.GetAgentSecret(ctx, agentID, "hf-token") + if err != nil { + return nil, err + } + if token == "" { + return nil, nil + } + return kbclient.New(p.cfg.BackendURL, token), nil + }, }, } diff --git a/internal/tools/kb.go b/internal/tools/kb.go index b2569a9..eefd5eb 100644 --- a/internal/tools/kb.go +++ b/internal/tools/kb.go @@ -25,11 +25,41 @@ import ( // KBDeps bundles the dependencies the KB tools need. Caller (main.go) // constructs once and reuses across all tool calls. type KBDeps struct { + // Client is the fallback plugin-level KB client (auths with + // Config.APIKey). When AgentClient resolves a per-agent client, + // that takes priority. Client *kbclient.Client ProfileRoot string // SessionFor func(agentID string) string // returns sessionID or "" Turn func(agentID string) int // current turn (best-effort, can return 0) HostCallTool func(ctx context.Context, agentID, toolName string, input json.RawMessage) (json.RawMessage, error) + + // AgentClient resolves a per-agent KBClient by reading the agent's + // hf-token via HostAPI.GetAgentSecret (decision #20). Returns + // (nil, nil) when no token is configured for the agent — caller + // then falls back to Client. (nil, err) on RPC / decrypt failure. + AgentClient func(ctx context.Context, agentID string) (*kbclient.Client, error) +} + +// clientForCall picks the right KBClient: per-agent if AgentClient +// returns one, else falls back to the shared plugin-level Client. +// Returns (nil, "") only when neither path produces a usable +// client (no configured token + no plugin-level apiKey). +func (d KBDeps) clientForCall(ctx context.Context, agentID string) (*kbclient.Client, string) { + if d.AgentClient != nil && agentID != "" { + c, err := d.AgentClient(ctx, agentID) + if err == nil && c != nil { + return c, "" + } + if err != nil { + return nil, "agent hf-token lookup failed: " + err.Error() + } + // no token for this agent — fall through to plugin-level + } + if d.Client != nil { + return d.Client, "" + } + return nil, "HF KB backend unavailable (no per-agent hf-token + no plugin-level apiKey configured)" } // kb tool inputs @@ -72,14 +102,15 @@ type evictResult struct { // ToolKBListKBs implements dynamic-kb-list-kbs. func ToolKBListKBs(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdkplugin.ToolResult, error) { - if deps.Client == nil { - return errResult("dynamic-kb-list-kbs: HF KB backend unavailable") + client, reason := deps.clientForCall(ctx, sdkplugin.AgentIDFromContext(ctx)) + if client == nil { + return errResult("dynamic-kb-list-kbs: " + reason) } var in listKBsInput if len(raw) > 0 { _ = json.Unmarshal(raw, &in) } - kbs, err := deps.Client.ListKBs(ctx, in.ProjectCode) + kbs, err := client.ListKBs(ctx, in.ProjectCode) if err != nil { return errResult("dynamic-kb-list-kbs: " + err.Error()) } @@ -97,8 +128,9 @@ func ToolKBListKBs(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdkpl // ToolKBListTopics implements dynamic-kb-list-topics. func ToolKBListTopics(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdkplugin.ToolResult, error) { - if deps.Client == nil { - return errResult("dynamic-kb-list-topics: HF KB backend unavailable") + client, reason := deps.clientForCall(ctx, sdkplugin.AgentIDFromContext(ctx)) + if client == nil { + return errResult("dynamic-kb-list-topics: " + reason) } var in listTopicsInput if err := json.Unmarshal(raw, &in); err != nil { @@ -107,7 +139,7 @@ func ToolKBListTopics(ctx context.Context, deps KBDeps, raw json.RawMessage) (sd if in.KBCode == "" { return errResult("dynamic-kb-list-topics: kb-code required") } - topics, err := deps.Client.ListTopics(ctx, in.KBCode) + topics, err := client.ListTopics(ctx, in.KBCode) if err != nil { return errResult("dynamic-kb-list-topics: " + err.Error()) } @@ -123,8 +155,9 @@ func ToolKBListTopics(ctx context.Context, deps KBDeps, raw json.RawMessage) (sd // ToolKBListFacts implements dynamic-kb-list-facts. func ToolKBListFacts(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdkplugin.ToolResult, error) { - if deps.Client == nil { - return errResult("dynamic-kb-list-facts: HF KB backend unavailable") + client, reason := deps.clientForCall(ctx, sdkplugin.AgentIDFromContext(ctx)) + if client == nil { + return errResult("dynamic-kb-list-facts: " + reason) } var in listFactsInput if err := json.Unmarshal(raw, &in); err != nil { @@ -133,7 +166,7 @@ func ToolKBListFacts(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdk if in.KBCode == "" || len(in.TopicIDs) == 0 { return errResult("dynamic-kb-list-facts: kb-code + topic-ids required") } - facts, err := deps.Client.ListFacts(ctx, in.KBCode, in.TopicIDs) + facts, err := client.ListFacts(ctx, in.KBCode, in.TopicIDs) if err != nil { return errResult("dynamic-kb-list-facts: " + err.Error()) } @@ -153,8 +186,9 @@ func ToolKBListFacts(ctx context.Context, deps KBDeps, raw json.RawMessage) (sdk // ToolKBCache implements dynamic-kb-cache. Per-agent session lookup // resolves the kb-block.json location. func ToolKBCache(ctx context.Context, deps KBDeps, agentID string, raw json.RawMessage) (sdkplugin.ToolResult, error) { - if deps.Client == nil { - return errResult("dynamic-kb-cache: HF KB backend unavailable") + client, reason := deps.clientForCall(ctx, agentID) + if client == nil { + return errResult("dynamic-kb-cache: " + reason) } sessionID := "" if deps.SessionFor != nil { @@ -186,7 +220,7 @@ func ToolKBCache(ctx context.Context, deps KBDeps, agentID string, raw json.RawM } var fetched []kbclient.Fact if len(toFetch) > 0 { - fetched, err = deps.Client.GetFacts(ctx, in.KBCode, toFetch) + fetched, err = client.GetFacts(ctx, in.KBCode, toFetch) if err != nil { return errResult("dynamic-kb-cache: " + err.Error()) }