feat(kb): per-agent hf-token via HostAPI.GetAgentSecret

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) <noreply@anthropic.com>
This commit is contained in:
h z
2026-06-06 00:32:41 +01:00
parent 9e24c52ae5
commit 1c737bb21c
2 changed files with 59 additions and 12 deletions

View File

@@ -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
},
},
}

View File

@@ -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 // <PLEXUM_PROFILE_ROOT>
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, "<reason>") 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())
}