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
- Variables
- func IsKnownToolSetID(id string) bool
- type Budget
- type BusyInfo
- type CommonDeps
- type LLMCallCounter
- func (c *LLMCallCounter) AddEvent(_ context.Context, _ string, _ any)
- func (c *LLMCallCounter) EndAgentExecute(_ context.Context, _ error)
- func (c *LLMCallCounter) EndChildAgent(_ context.Context, _ error)
- func (c *LLMCallCounter) EndLLMCall(_ context.Context, _ *trace.LLMCallData, _ error)
- func (c *LLMCallCounter) EndSubAgent(_ context.Context, _ error)
- func (c *LLMCallCounter) EndToolExec(_ context.Context, _ map[string]any, _ error)
- func (c *LLMCallCounter) Finish(_ context.Context) error
- func (c *LLMCallCounter) LLMCalls() int64
- func (c *LLMCallCounter) StartAgentExecute(ctx context.Context) context.Context
- func (c *LLMCallCounter) StartChildAgent(ctx context.Context, _ string) context.Context
- func (c *LLMCallCounter) StartLLMCall(ctx context.Context) context.Context
- func (c *LLMCallCounter) StartSubAgent(ctx context.Context, _ string) context.Context
- func (c *LLMCallCounter) StartToolExec(ctx context.Context, _ string, _ map[string]any) context.Context
- type LoadOrCreateSessionInput
- type ToolSetDeps
- type ToolSetResolver
- type TurnHandle
Constants ¶
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.
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 ¶
var KnownToolSetIDs = []string{ ToolSetCoreRO, ToolSetSlackRO, ToolSetNotion, ToolSetGitHub, ToolSetWebFetch, ToolSetJira, }
KnownToolSetIDs is the canonical list of identifiers a planner is allowed to request. Anything outside this list is rejected at plan validation.
var KnownToolSetIDsNoCore = []string{ ToolSetSlackRO, ToolSetNotion, ToolSetGitHub, ToolSetWebFetch, ToolSetJira, }
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.
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.
var KnownToolSetIDsWorkspaceChannel = []string{ ToolSetCaseMulti, ToolSetSlackRO, ToolSetNotion, ToolSetGitHub, ToolSetWebFetch, ToolSetJira, }
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 ¶
IsKnownToolSetID reports whether id is a member of KnownToolSetIDs.
Types ¶
type Budget ¶
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 (*Budget) CanPlannerCall ¶
CanPlannerCall reports whether one more planner LLM call is within budget.
func (*Budget) FormatPrefix ¶
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 (*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 (*LLMCallCounter) StartLLMCall ¶
func (c *LLMCallCounter) StartLLMCall(ctx context.Context) context.Context
func (*LLMCallCounter) StartSubAgent ¶
func (*LLMCallCounter) StartToolExec ¶
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.
Source Files
¶
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). |