agent

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 28, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Package agent contains the Slack-independent agent runtime shared by the `casebound` (Case-bound mention) and `draft` (open-mode case draft) modes, and reserved for the future `triage` mode that will run after Case creation. Slack SDK / pkg/service/slack imports are forbidden inside this package; modes must communicate with their host via small handler interfaces (e.g. proposal.Handler, casebound.Handler).

Index

Constants

View Source
const (
	DefaultHeartbeatInterval   = 10 * time.Second
	DefaultHeartbeatStaleAfter = 30 * time.Second
)

Default values for the per-CommonDeps heartbeat config when callers leave HeartbeatInterval / HeartbeatStaleAfter zero. These keep tests terse but production wiring should set them explicitly via CLI flags.

View Source
const (
	ToolSetCoreRO   = "core_ro"
	ToolSetSlackRO  = "slack_ro"
	ToolSetNotion   = "notion"
	ToolSetGitHub   = "github"
	ToolSetWebFetch = "webfetch"
	ToolSetJira     = "jira"
	// ToolSetCaseWrite is the writer toolset for the single case the turn is
	// pinned to: the full casewriter set (case__update_case, case__assign,
	// case__unassign, and the mode-appropriate case__update_case_status /
	// case__close_case). Every mention-driven host grants it, so a sub-agent can
	// carry out any case edit the user asked for. Note that a mention turn's
	// terminal `materialize` decision replaces title / description wholesale, so
	// a host offering both must tell the planner to pick one path per turn (see
	// threadcase's system prompt).
	ToolSetCaseWrite = "case_write"
	// ToolSetCaseMulti is the cross-case ("workspace-scoped") toolset used by the
	// workspace-channel agent. Unlike core/case_write (pinned to one case),
	// its tools take case_id as a call-time argument so a single turn can operate
	// across every case the requesting user can access. Advertised only via
	// KnownToolSetIDsWorkspaceChannel (never the default lists) so it is not
	// offered to the per-case mention / proposal planners.
	ToolSetCaseMulti = "case_multi"
)

ToolSet IDs known to the planner. Sub-agents request a subset of these per investigation task and the resolver below maps each ID to a concrete []gollem.Tool slice.

Variables

KnownToolSetIDs is the canonical list of identifiers a planner is allowed to request. Anything outside this list is rejected at plan validation.

KnownToolSetIDsNoCore is KnownToolSetIDs without the core (action) toolset. Thread-mode agents advertise this list to the planner: a thread-mode workspace manages no Actions, so the planner must never be offered the core read tools (list/get action). Paired with ToolSetDeps.OmitCore so the resolver also withholds the underlying tools.

View Source
var KnownToolSetIDsThreadWrite = append(append([]string{}, KnownToolSetIDsNoCore...), ToolSetCaseWrite)

KnownToolSetIDsThreadWrite is KnownToolSetIDsNoCore plus the case writer toolset. Thread-mode agents advertise this to the planner on mention turns, where a concrete case exists to act on and a human asked for the change: the sub-agent may then edit, assign, and transition that case. Materialize and creation turns advertise the plain KnownToolSetIDsNoCore instead, so the planner is never offered a writer tool the resolver cannot wire — the prompt-vs-capability mismatch the architecture rule forbids.

KnownToolSetIDsWorkspaceChannel is the planner-advertised list for the workspace-channel agent: the cross-case toolset plus the read-only auxiliary toolsets. It deliberately omits core_ro (the case-pinned action read tools) — case_multi carries the cross-case action tools instead.

Functions

func IsKnownToolSetID

func IsKnownToolSetID(id string) bool

IsKnownToolSetID reports whether id is a member of KnownToolSetIDs.

Types

type Budget

type Budget struct {
	PlannerMax      int
	PlannerUsed     int
	SubAgentLoopMax int
}

Budget tracks per-turn LLM call ceilings. It is constructed by each turn (NewBudget) and updated by the turn driver after every planner call.

The budget model is two controls — there is deliberately NO running "total sub-agent task count" across a turn (see .claude/rules/architecture.md → "Agent runtime vocabulary" / "Budget"):

  • PlannerMax bounds the number of rounds in a turn.
  • SubAgentLoopMax is the per-sub-agent budget, granted fresh every round (so the sub-agent budget recovers per round).

Per-round fan-out is bounded by plan validation, so total sub-agent work is naturally bounded without a separate per-turn total cap.

All fields are caller-controlled so the surrounding code can vary the limits per environment (CLI flags) without re-importing constants.

func NewBudget

func NewBudget(plannerMax, subAgentLoopMax int) *Budget

NewBudget builds a fresh Budget snapshot for a single turn.

func (*Budget) CanPlannerCall

func (b *Budget) CanPlannerCall() bool

CanPlannerCall reports whether one more planner LLM call is within budget.

func (*Budget) FormatPrefix

func (b *Budget) FormatPrefix() string

FormatPrefix renders the prefix line that gets prepended to every planner LLM user input so the LLM can plan against the current budget state.

Example: "[budget] planner round 3/8".

type BusyInfo

type BusyInfo struct {
	// StartedAt is the live owner's TurnStartedAt timestamp.
	StartedAt time.Time
	// OwnerID is the live owner's TurnOwnerID, useful for debug logs.
	OwnerID string
}

BusyInfo describes the live-owner state at the moment another caller failed to acquire the turn lock. It is passed through to host-side handlers (proposal.Handler.PostBusy / casebound busy notification) so the user-facing message can mention how long the in-flight turn has been running, etc.

type CommonDeps

type CommonDeps struct {
	Repo        interfaces.Repository
	Registry    *model.WorkspaceRegistry
	LLMClient   gollem.LLMClient
	HistoryRepo gollem.HistoryRepository
	TraceRepo   trace.Repository

	// Optional sub-agent service / client deps. nil → the corresponding tool
	// set is empty.
	SlackSearch    slacktool.SearchService
	SlackBot       slacktool.BotService
	SlackRetriever slacktool.MessageRetriever
	NotionClient   notiontool.Client
	GitHubClient   *githubtool.Client
	WebFetchClient *webfetch.Client

	// JiraTools carries the already-expanded Jira read tools (see
	// pkg/agent/tool/jira). Unlike NotionClient/GitHubClient/WebFetchClient
	// this is not a client type: gollem exposes no exported helper to turn a
	// gollem.ToolSet into []gollem.Tool, so config.Jira.Configure expands it
	// once at startup and hands the result through as a plain tool slice.
	// nil/empty means Jira is not configured. Consumed both directly here
	// (casebound's always-on tool set) and via agent.ToolSetDeps.Jira for
	// the sub-agent resolver (proposal / threadcase investigation).
	JiraTools []gollem.Tool

	// Action mutator interfaces, used by `core` toolset. Required for
	// case-bound mode (which uses the full mutating tool set). Optional for
	// draft mode (read-only sub-agents).
	ActionUC     core.ActionMutator
	ActionStepUC core.ActionStepMutator

	// CaseUC backs the casewriter tools (case__update_case /
	// case__update_case_status / case__close_case) in case-bound mode. Optional:
	// nil means the case-bound agent cannot edit the case itself (the tools are
	// not built).
	CaseUC casewriter.CaseMutator

	// CaseRefUC backs the case_ref read tools
	// (core__search_referenceable_cases / core__get_referenceable_cases).
	// Optional: nil disables those tools (no case_ref fields configured).
	CaseRefUC core.CaseRefReader

	// CaseMultiUC / CaseMultiActionUC back the cross-case (case_multi) tool set
	// used by the workspace-channel agent. Unlike CaseUC (single-case
	// casewriter.CaseMutator), these carry the full cross-case read+write
	// surface whose tools take case_id at call time. Wired from the concrete
	// usecases via usecase.NewCaseMultiCaseAdapter / NewCaseMultiActionAdapter.
	// Optional: nil disables the cross-case tools.
	CaseMultiUC       casemulti.CaseUsecase
	CaseMultiActionUC casemulti.ActionUsecase

	// MemoUC backs the Case-scoped memo tools (memo__*) in case-bound mode.
	// Optional: nil means the agent gets no memo tools.
	MemoUC memotool.MemoMutator

	// KnowledgeAccessor backs the read-only workspace knowledge tools
	// (knowledge__search/get/list_tags). When set, read access is always
	// offered. KnowledgeMutator backs the write tools (create/update); each
	// mode wires the write tools only when the agent is permitted to mutate
	// shared knowledge (i.e. not while processing a private case). Both are
	// optional: nil means the corresponding tools are not built.
	KnowledgeAccessor knowledgetool.KnowledgeAccessor
	KnowledgeMutator  knowledgetool.KnowledgeMutator

	// HeartbeatInterval / HeartbeatStaleAfter govern §5.3 turn-lock activity
	// detection. Zero values fall back to DefaultHeartbeatInterval /
	// DefaultHeartbeatStaleAfter.
	HeartbeatInterval   time.Duration
	HeartbeatStaleAfter time.Duration
}

CommonDeps groups dependencies shared across agent modes. Each mode's UseCase embeds (or holds a pointer to) one of these; mode-specific configuration (e.g. draft's plannerLoopMax) lives on the mode UseCase itself.

func NewCommonDeps

func NewCommonDeps(repo interfaces.Repository, llm gollem.LLMClient, historyRepo gollem.HistoryRepository, traceRepo trace.Repository) (*CommonDeps, error)

NewCommonDeps validates inputs and returns a populated CommonDeps. It does not enforce optional fields (slack/notion/github) but does require the core trio (Repo / LLMClient / HistoryRepo / TraceRepo) so wiring errors fail fast.

func (*CommonDeps) LoadOrCreateSession

func (d *CommonDeps) LoadOrCreateSession(ctx context.Context, in LoadOrCreateSessionInput) (*model.Session, error)

LoadOrCreateSession fetches the Session for (ChannelID, ThreadTS) or builds a fresh in-memory one without persisting. Persistence happens at turn finalisation (mode-specific) so a half-failed turn never commits a new Session row.

Errors only surface when the repository call itself fails. A missing Session is a normal "build a new one" branch.

func (*CommonDeps) StartTurn

func (d *CommonDeps) StartTurn(parent context.Context, ssn *model.Session, triggerKey string) (*TurnHandle, error)

StartTurn acquires the turn lock for (ssn.ChannelID, ssn.ThreadTS), starts the heartbeat goroutine, and returns a TurnHandle the mode driver can dispatch on. It does NOT panic on a nil/unset CommonDeps; instead a goerr is returned so the host can decide whether to propagate.

The semantics for the four AcquireResult shapes are encoded in TurnHandle:

  • Acquired=true → run body; defer Release.
  • Idempotent=true → drop silently (no Slack post).
  • BusyOwner!=nil → host should PostBusy; do NOT run body.
  • error → goerr wrapped, surfaced to caller.

type LLMCallCounter

type LLMCallCounter struct {
	// contains filtered or unexported fields
}

LLMCallCounter is a minimal gollem trace.Handler that does nothing except count StartLLMCall invocations. It is attached to sub-agents (via gollem.WithTrace) so the driver can read back how many ReAct loops the sub-agent burned through and surface the number in the user-facing trace.

The counter is safe for concurrent use; the underlying value is an atomic int64.

func NewLLMCallCounter

func NewLLMCallCounter() *LLMCallCounter

NewLLMCallCounter returns a fresh counter ready to be passed to gollem.

func (*LLMCallCounter) AddEvent

func (c *LLMCallCounter) AddEvent(_ context.Context, _ string, _ any)

func (*LLMCallCounter) EndAgentExecute

func (c *LLMCallCounter) EndAgentExecute(_ context.Context, _ error)

func (*LLMCallCounter) EndChildAgent

func (c *LLMCallCounter) EndChildAgent(_ context.Context, _ error)

func (*LLMCallCounter) EndLLMCall

func (c *LLMCallCounter) EndLLMCall(_ context.Context, _ *trace.LLMCallData, _ error)

func (*LLMCallCounter) EndSubAgent

func (c *LLMCallCounter) EndSubAgent(_ context.Context, _ error)

func (*LLMCallCounter) EndToolExec

func (c *LLMCallCounter) EndToolExec(_ context.Context, _ map[string]any, _ error)

func (*LLMCallCounter) Finish

func (c *LLMCallCounter) Finish(_ context.Context) error

func (*LLMCallCounter) LLMCalls

func (c *LLMCallCounter) LLMCalls() int64

LLMCalls returns the number of StartLLMCall invocations seen so far.

func (*LLMCallCounter) StartAgentExecute

func (c *LLMCallCounter) StartAgentExecute(ctx context.Context) context.Context

func (*LLMCallCounter) StartChildAgent

func (c *LLMCallCounter) StartChildAgent(ctx context.Context, _ string) context.Context

func (*LLMCallCounter) StartLLMCall

func (c *LLMCallCounter) StartLLMCall(ctx context.Context) context.Context

func (*LLMCallCounter) StartSubAgent

func (c *LLMCallCounter) StartSubAgent(ctx context.Context, _ string) context.Context

func (*LLMCallCounter) StartToolExec

func (c *LLMCallCounter) StartToolExec(ctx context.Context, _ string, _ map[string]any) context.Context

type LoadOrCreateSessionInput

type LoadOrCreateSessionInput struct {
	ChannelID string
	ThreadTS  string

	// Case-bound mode fields. Zero values when this is open-mode.
	WorkspaceID string
	CaseID      int64
	// DetectActionID, when non-nil, runs a side lookup for an Action whose
	// SlackMessageTS matches ThreadTS (used by case-bound mode to link the
	// session to the action thread). Pass nil to skip.
	DetectActionID func(ctx context.Context, workspaceID, threadTS string) (int64, error)

	// Open-mode fields. Zero values when case-bound.
	CreatorUserID string
	ProposalID    model.CaseProposalID
}

LoadOrCreateSessionInput collects the per-call state used to materialise a fresh Session if one does not yet exist. It mirrors the shape of the legacy AgentUseCase.loadOrCreateSession parameters and adds the open-mode fields for symmetry across the case-bound and draft modes.

type ToolSetDeps

type ToolSetDeps struct {
	Core      core.Deps
	Slack     slacktool.Deps
	Notion    notiontool.Deps
	GitHub    *githubtool.Client
	WebFetch  *webfetch.Client
	Knowledge knowledgetool.Deps

	// Jira carries the already-expanded Jira read tools (see
	// pkg/agent/tool/jira). nil/empty means Jira is not configured, so the
	// "jira" ToolSet ID resolves to nothing.
	Jira []gollem.Tool

	// CaseWrite backs the case_write toolset (the full single-case writer set).
	// The tools are built when CaseUC and CaseID identify a concrete case; a zero
	// value (no case yet, or no mutator wired) leaves the toolset empty so
	// requesting the ID resolves to nothing. StatusSet selects the mode-specific
	// "mark done" tool (case__update_case_status when set, case__close_case when
	// not) and Schema drives case__update_case's custom-field coercion.
	CaseWrite casewriter.Deps

	// OmitCore omits the core (action) toolset entirely. Set by thread-mode
	// agents: a thread-mode workspace manages no Actions, so even the
	// read-only list/get-action tools must not exist. Without this the
	// resolver would always build them (they only need Repo), since the
	// core read tools do not depend on ActionUC being wired.
	OmitCore bool

	// CaseMulti backs the case_multi (cross-case) toolset. Built only when
	// CaseMulti.CaseUC is non-nil (the workspace-channel host wires it); a nil
	// CaseUC leaves the toolset empty so requesting the ID resolves to nothing.
	CaseMulti casemulti.Deps
}

ToolSetDeps carries the per-turn deps that flavor each toolset's binding. Optional fields (SlackSearch / NotionClient / GitHubClient) may be nil; the corresponding toolset is empty in that case.

type ToolSetResolver

type ToolSetResolver struct {
	// contains filtered or unexported fields
}

ToolSetResolver builds gollem.Tool slices for sub-agents based on a list of ToolSet IDs. The resolver is created once per turn (with the deps that vary per turn — workspace, case, slack/notion/github clients) and called per sub-agent.

func NewToolSetResolver

func NewToolSetResolver(d ToolSetDeps) *ToolSetResolver

NewToolSetResolver builds the per-toolset slices once so each sub-agent just picks the union of its requested IDs. The "core" pool is the read-only subset (list / get only) — investigation sub-agents must not mutate the case while a turn is forming.

func (*ToolSetResolver) Resolve

func (r *ToolSetResolver) Resolve(ids []string) []gollem.Tool

Resolve returns the concatenated tool list for the requested IDs. Unknown IDs are skipped (they should already have been rejected by plan validation, but Resolve never panics so a stray ID does not crash a turn).

type TurnHandle

type TurnHandle struct {
	// Ctx is the cancellable context for this turn. Heartbeat owner-mismatch
	// (and Phase B interrupt) cancels it via cancelTurn; the mode body should
	// honour ctx.Done() at every awaitable boundary.
	Ctx context.Context
	// Session reflects the post-acquire state (TurnOwnerID, TurnHeartbeatAt
	// already populated).
	Session *model.Session
	// OwnerID is what the mode passes back to ReleaseTurnLock / Heartbeat.
	OwnerID string
	// Acquired is true iff the turn body should run.
	Acquired bool
	// Reclaimed is true when the lock was claimed from a stale prior owner.
	Reclaimed bool
	// Idempotent is true when the current trigger matches the live owner's
	// trigger (Slack duplicate event); the mode should drop and return nil.
	Idempotent bool
	// BusyOwner, when not nil, signals the lock is held by a fresh owner;
	// the mode should call its host's PostBusy and return.
	BusyOwner *model.Session
	// Release stops the heartbeat goroutine and releases the lock. Always
	// safe to call (no-op when Acquired is false). Pass an outer-scoped ctx
	// (typically the parent ctx, not Ctx, so a turn cancellation does not
	// strand the release).
	Release func(ctx context.Context)
}

TurnHandle bundles everything a mode's RunTurn body needs after acquiring the turn lock and starting the heartbeat goroutine. Callers MUST defer Release() before doing any work; the LIFO defer order guarantees the heartbeat goroutine stops before the lock is released.

Directories

Path Synopsis
Package casebound contains the agent runtime for case-bound channels — Slack channels that already have an associated Case.
Package casebound contains the agent runtime for case-bound channels — Slack channels that already have an associated Case.
Package job is the event-driven Agent Job runtime.
Package job is the event-driven Agent Job runtime.
Package planexec hosts the reusable plan-and-execute loop shared by the proposal (case-draft) host and the planexec-strategy Job host.
Package planexec hosts the reusable plan-and-execute loop shared by the proposal (case-draft) host and the planexec-strategy Job host.
Package draft contains the open-mode (case-draft) agent runtime: a plan/execute loop that coordinates a planner LLM and parallel sub-agent investigations to produce a CaseProposal for the host (Slack) to render.
Package draft contains the open-mode (case-draft) agent runtime: a plan/execute loop that coordinates a planner LLM and parallel sub-agent investigations to produce a CaseProposal for the host (Slack) to render.
Package threadcase hosts the thread-mode agent: a plan-and-execute turn (planexec.Runner) that runs when a Case is created from a monitored channel post (materialize the Case fields) or when the bot is mentioned in a Case thread (investigate and respond / update fields / close).
Package threadcase hosts the thread-mode agent: a plan-and-execute turn (planexec.Runner) that runs when a Case is created from a monitored channel post (materialize the Case fields) or when the bot is mentioned in a Case thread (investigate and respond / update fields / close).
Package wsagent hosts the workspace-channel agent: a plan-and-execute turn (planexec.Runner) that runs when the bot is mentioned in a channel-mode workspace's configured workspace channel ([slack] workspace_channel).
Package wsagent hosts the workspace-channel agent: a plan-and-execute turn (planexec.Runner) that runs when the bot is mentioned in a channel-mode workspace's configured workspace channel ([slack] workspace_channel).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL