Documentation
¶
Overview ¶
Package kernel assembles the agentkit Kernel this application runs its agents on: the tool factory, the observability middleware, and the typed view of the per-Process scope that both of them read.
Index ¶
- Constants
- func Build(d Deps) (*agentkit.Kernel, error)
- func JobRunSubject(workspaceID string, caseID int64, jobID string) agentkit.SubjectRef
- func NewToolFactory(d ToolDeps) (agentkit.ToolFactory, error)
- func NoDuplicateSideEffects() agentkit.ServeOption
- func RegisterTaskAgent(reg *agentkit.Registry, limiter agentkit.Limiter, store agentkit.HistoryStore) (agentkit.Agent[react.Input], error)
- func RequiresActor(name agentkit.AgentName) bool
- func Serve(ctx context.Context, k *agentkit.Kernel, opts ...agentkit.ServeOption) error
- func ThreadSubject(sessionID string) agentkit.SubjectRef
- func TriggerKey(channelID, threadTS, triggerTS string) string
- func ValidateSpawn(name agentkit.AgentName, sc Scope) error
- func WithBudget(parent map[string]string, amount pricing.NanoUSD) map[string]string
- func WithToolSets(parent map[string]string, toolSets []string) map[string]string
- type Budgets
- type BusyTurn
- type Deps
- type Locator
- type ModelDef
- type ModelPolicy
- func (p ModelPolicy) Cost(sc Scope, m agentkit.Metrics) pricing.NanoUSD
- func (p ModelPolicy) IsZero() bool
- func (p ModelPolicy) ModelName(sc Scope) string
- func (p ModelPolicy) Refs() []string
- func (p ModelPolicy) Remaining(m map[string]string, metrics agentkit.Metrics) (remaining, total pricing.NanoUSD)
- func (p ModelPolicy) RemainingFunc() func(map[string]string, agentkit.Metrics) (pricing.NanoUSD, pricing.NanoUSD)
- func (p ModelPolicy) Resolve(proc *agentkit.Process) budget.RunLimit
- type ModelPolicyInput
- type Scope
- type SlotGate
- type SlotHold
- type SlotRef
- type ToolDeps
- type ToolSetProbe
Constants ¶
const ( // AgentCaseChannel is the ReAct agent behind a mention in a channel-mode // case channel. AgentCaseChannel agentkit.AgentName = "case-channel" // AgentCaseThread is the plan-execute agent behind a mention in a // thread-mode case thread. AgentCaseThread agentkit.AgentName = "case-thread" // AgentCaseThreadCreate is the plan-execute agent that materialises a new // thread-mode case from the conversation that triggered it. AgentCaseThreadCreate agentkit.AgentName = "case-thread-create" // AgentWorkspace is the cross-case plan-execute agent in a workspace // channel. AgentWorkspace agentkit.AgentName = "workspace" // AgentProposal is the plan-execute agent that drafts a case before one // exists. AgentProposal agentkit.AgentName = "proposal" // AgentJob is the plan-execute agent behind a Job configured with the // planexec strategy. AgentJob agentkit.AgentName = "job" // AgentJobSimple is the ReAct agent behind a Job configured with the simple // strategy. AgentJobSimple agentkit.AgentName = "job-simple" // AgentAssist is the ReAct agent behind the assist batch command. AgentAssist agentkit.AgentName = "assist" // AgentTask is the ReAct sub-agent a plan-execute agent spawns per planned // task. AgentTask agentkit.AgentName = "task" )
Agent names. These values are persisted on every Process row, so a running deployment always has in-flight Processes referring to them by name. Renaming one strands those Processes with ErrUnknownAgent and no way to finish, so the names are fixed for good; a change in what an agent DOES is expressed by bumping its strategy version, which DecodeState migrates.
const ( ProviderOpenAI = "openai" ProviderClaude = "claude" ProviderGemini = "gemini" )
The providers a model definition may name. They are the providers config.LLM knows how to build a client for; anything else is a typo in the configuration document rather than a model this build can reach.
const ( // SubjectSlackThread serialises the agent turns of one Slack thread. Its ID // is the model.Session.ID, which is why a host must claim (and therefore // persist) the Session before it spawns. SubjectSlackThread = "slack-thread" // SubjectJobRun serialises the runs of one configured Job on one case. SubjectJobRun = "job-run" )
Subject kinds. A Subject is agentkit's single-flight lock: at most one open Process may hold a given (Kind, ID) pair, and Spawn reports ErrSubjectBusy for the second.
const ToolSetsAll = "*"
ToolSetsAll is the toolsets value meaning "everything this agent kind is entitled to". A root Process uses it; a sub-agent Process instead carries the explicit subset its task was planned with.
Variables ¶
This section is empty.
Functions ¶
func Build ¶
Build assembles the Kernel: the tool factory, the claim bracket that carries the request-scoped context and the trace sinks, the prompt-cache setting every LLM call runs under, and the effect middlewares that record LLM and tool calls.
func JobRunSubject ¶
func JobRunSubject(workspaceID string, caseID int64, jobID string) agentkit.SubjectRef
JobRunSubject builds the turn-lock subject for one Job on one case.
func NewToolFactory ¶
func NewToolFactory(d ToolDeps) (agentkit.ToolFactory, error)
NewToolFactory returns the agentkit.ToolFactory the Kernel is built with. It runs once per claim and narrows ToolDeps down to the Process's own scope.
func NoDuplicateSideEffects ¶
func NoDuplicateSideEffects() agentkit.ServeOption
NoDuplicateSideEffects is the Serve option every worker in this application MUST pass; Serve above applies it for you. It is not tuning — it is a correctness requirement of the tools these agents carry.
A transition runs its effect and is checkpointed afterwards, so a claim that dies in between leaves a Process whose last checkpoint still asks for the call that already happened. agentkit's default lets three such takeovers re-run it; its own documentation says callers that cannot tolerate duplicated side effects set the bound to 0. This application cannot: core__create_action, case__update_case, memo / knowledge creation and slack__post_message all take effect on the first call and carry no idempotency key, so a re-run means a second Action, a second post, a second record.
The cost is that a run whose instance dies mid-transition fails instead of resuming — which is exactly what the previous runtime did with a crashed turn, so nothing regresses. Remove this only once every side-effecting tool is idempotent under a replayed (process, call) pair.
func RegisterTaskAgent ¶
func RegisterTaskAgent(reg *agentkit.Registry, limiter agentkit.Limiter, store agentkit.HistoryStore, ) (agentkit.Agent[react.Input], error)
RegisterTaskAgent registers the ReAct sub-agent every plan-execute agent spawns per planned task, and returns the handle they are all built with.
It lives here, and is called exactly once per registry, because agentkit keys a Process on the agent NAME: a second registration under AgentTask is an error, and giving each host its own name would mean maintaining a separate tool palette for each — for sub-agents that do the same thing.
func RequiresActor ¶
RequiresActor reports whether an agent may only run with an identified Slack user behind it.
This is not a formality. A context with no auth token is read by the usecase layer as a system context and BYPASSES private-case access control entirely (see tokenActor in pkg/usecase/case_access.go). So for an agent working on a person's request, a missing actor is not "reduced access" — it is full access, and a private case becomes readable by someone who is not in its channel.
Every human-triggered agent is listed. The pre-agentkit hosts injected the token only in the workspace agent, so the mention agents did read private cases for a non-member; that is the behaviour being corrected, not preserved.
The unattended agents are NOT listed. A Job and the assist batch run on a schedule with nobody behind them, so there is no actor to name and their system-context access is the intended one. A sub-agent inherits its parent's metadata, so it carries whatever actor the parent was given. AgentCaseThreadCreate is the scoped exception. A thread-mode case may be raised by an integration bot's intake post that names no human, so demanding an actor there would refuse a legitimate creation — the same relaxation Case.ValidateNew already makes for the reporter. It is safe because a create turn's palette (KnownToolSetIDsNoCore) carries no case-reading tool at all: there is no private case for a missing actor to widen access to.
func Serve ¶
Serve runs the agent worker. Every worker in this application starts here rather than calling Kernel.Serve directly, so the guard below cannot be forgotten at a call site: it is prepended to whatever the caller passes.
Caller options come after it and can therefore still override it, which is what a test needs when it is measuring the guard itself. Production code has no reason to.
func ThreadSubject ¶
func ThreadSubject(sessionID string) agentkit.SubjectRef
ThreadSubject builds the turn-lock subject for a Slack thread.
func TriggerKey ¶
TriggerKey builds the idempotency key for a Slack-triggered turn. Spawn resolves an existing key to the Process it already created, which is how a re-delivered Slack event is dropped instead of starting a second turn.
agentkit evaluates the idempotency key BEFORE the subject, so a duplicate delivery is answered with the original Process rather than with "busy" — the same precedence the Session turn lock applied.
func ValidateSpawn ¶
ValidateSpawn checks a scope against the agent it is about to launch. A host calls it before Spawn.
This is the enforcing check, not the tool factory's. Spawn is the last point where a bad scope can be reported to someone who can act on it: once the Process exists, a claim that refuses to run it is put back as pending with a backoff and never consumes the retry budget, so the row would requeue forever and hold its Subject with it — no later turn on that thread could start.
func WithBudget ¶
WithBudget returns a copy of the metadata map carrying a different spend ceiling. It is the counterpart of WithToolSets for the other thing a strategy decides about a child it is spawning, and it copies for the same reason: SpawnChild's WithMetadata REPLACES the parent's map.
A zero or negative amount REMOVES the key. That reads back as "not specified" and hands the child the deployment default — the same meaning Scope.Metadata gives an unset budget, so the two cannot disagree about what an absent key means.
func WithToolSets ¶
WithToolSets returns a copy of the metadata map carrying a different toolset list. It is what a strategy uses when spawning a child, because SpawnChild's WithMetadata REPLACES the parent's map rather than merging into it: rebuilding the map from scratch there would drop the workspace and case the child needs to have any tools at all.
Types ¶
type Budgets ¶
type Budgets struct {
// Root applies to every Process an application entry point spawns.
Root budget.Root
// Task applies to the sub-agent Processes a plan-execute run spawns.
Task budget.Config
}
Budgets are the ceilings each class of Process runs under. Root and sub-agent are separate because a sub-agent is one investigation and a root run is the whole turn; giving them one number would either starve the turn or let a single task spend it all.
They are also bounded by different quantities: a root run's spend ceiling is money, resolved per run from the model it generates through, while a sub-agent's is tokens. See the budget package for why.
type BusyTurn ¶
type BusyTurn struct {
ProcessID agentkit.ProcessID
// StartedAt is when the holding Process was created, which is the moment the
// turn began.
StartedAt time.Time
}
BusyTurn describes the run currently holding a subject. It is what a host renders its "already working on this" message from.
type Deps ¶
type Deps struct {
// Repo is the durable Process store.
Repo agentkit.Repository
// History persists each Process's conversation as immutable versions.
History agentkit.HistoryStore
// LLM is the default model. A strategy that binds a model role to something
// else does so through WithModelRole; an unbound role falls back here.
LLM gollem.LLMClient
// Trace is where each claim's archive is written.
Trace trace.Repository
// Tools carries the clients and usecases the tool factory builds from.
Tools ToolDeps
// Budgets are the ceilings Strategy.Limit answers with.
Budgets Budgets
// Models is which model each run generates through and what it may spend.
// Required: without it a run has neither a priced ceiling nor a way to reach
// a model other than the default one.
Models ModelPolicy
// Agents is the registry the application filled with its strategies before
// building the Kernel. agentkit requires every Register to complete before
// the first Spawn or Serve, so registration is the caller's job and this is
// the finished result.
Agents *agentkit.Registry
// Slots is the deployment-wide concurrency gate. Optional: nil leaves every
// run ungated, which is what a deployment that configured no limit wants.
// Only runs whose Scope sets SlotGated are subject to it.
Slots SlotGate
}
Deps is everything the Kernel is built from. Every field is required.
type Locator ¶
type Locator interface {
// Busy names the run holding subject, or (nil, nil) when none does.
Busy(ctx context.Context, subject agentkit.SubjectRef) (*BusyTurn, error)
// ByTrigger names the run an earlier delivery of the same trigger already
// started, or "" when there is none. Spawn resolves an idempotency key
// silently — it returns the existing id without saying that it is existing —
// so asking first is the only way to tell a re-delivery from a fresh event.
ByTrigger(ctx context.Context, key string) (agentkit.ProcessID, error)
}
Locator answers "who already holds this" — by subject, or by the trigger that started a run. It is deliberately narrower than agentkit.Repository: the application does not call the persistence SPI, and a host legitimately needs only these two answers from it.
func NewLocator ¶
func NewLocator(repo agentkit.Repository) (Locator, error)
NewLocator wraps a Repository as a Locator.
type ModelDef ¶
type ModelDef struct {
// Ref is the name a Job or the CLI names this model by. Unique across every
// definition the deployment loaded.
Ref string
// Provider is which client builds it: openai, claude or gemini.
Provider string
// Model is the model name handed to that provider.
Model string
// Rate prices one token of each kind.
Rate pricing.Rate
}
ModelDef is one model an operator declared usable, with what it costs.
It is the resolved form of a global config [[llm_model]] entry: the reference name has already been decided (the alias, or the model name when the entry declares no alias) and the prices have already been converted out of the dollars-per-million-tokens the operator writes.
type ModelPolicy ¶
type ModelPolicy struct {
// contains filtered or unexported fields
}
ModelPolicy answers, for one run, which model it generates through and what it is judged against.
Both halves live in one value because they must agree: resolving the model from one place and the price from another is how a run ends up generating with a cheap model while being metered at an expensive one's rate, or the reverse. Everything here is decided at startup and never changes afterwards, which is also what makes it safe to read on the transition hot path.
func NewModelPolicy ¶
func NewModelPolicy(in ModelPolicyInput) (ModelPolicy, error)
NewModelPolicy builds the policy. One model role is defined per supplied client; the default model gets none, because an unbound role already resolves to the Kernel's positional client and defining one would only add a second name for it.
func (ModelPolicy) Cost ¶
Cost prices what a run spent, at the rate of the model its scope names. It is what the run record stores, so a later edit to the configured price cannot rewrite history.
It takes the scope rather than the Process because every caller already holds one: a completion handler reads the scope to find the run's records, and re-deriving it here would parse the same metadata twice.
func (ModelPolicy) IsZero ¶
func (p ModelPolicy) IsZero() bool
IsZero reports whether this is the empty policy, which a deployment with no LLM configured carries.
func (ModelPolicy) ModelName ¶
func (p ModelPolicy) ModelName(sc Scope) string
ModelName names the model a run generated through — the provider's own model name, not the reference name, because that is the value an operator can match against a provider's billing.
func (ModelPolicy) Refs ¶
func (p ModelPolicy) Refs() []string
Refs lists every defined reference name, sorted. For error messages and tests.
func (ModelPolicy) Remaining ¶
func (p ModelPolicy) Remaining(m map[string]string, metrics agentkit.Metrics, ) (remaining, total pricing.NanoUSD)
Remaining reports what the run this metadata describes may still spend, and the total it was allowed. It is what a planner is shown so it can size the tasks it plans, and what a per-task allowance is carved out of.
It takes the metadata rather than the Process because the caller that needs it is a strategy deciding how to split what is left, and a strategy holds a Syscalls, not a row.
remaining is clamped at zero. A run whose children folded in more than its ceiling has nothing left to give away, not a negative amount — and handing a negative figure to a planner would invite it to allocate one.
func (ModelPolicy) RemainingFunc ¶
func (p ModelPolicy) RemainingFunc() func(map[string]string, agentkit.Metrics) (pricing.NanoUSD, pricing.NanoUSD)
RemainingFunc returns Remaining in the shape a plan-execute host hands to planexec, or nil for the empty policy.
The nil is the point: a deployment with no LLM configured prices nothing, so Remaining would answer "$0.00 of $0.00" and every plan allocating a positive budget would be rejected. nil turns per-task budgets off instead, which is the correct reading of "there is no figure here". Keeping the check in one place stops each of the five hosts from having to remember it.
type ModelPolicyInput ¶
type ModelPolicyInput struct {
// Defs are every model the deployment declared. Reference names must be
// unique, and every definition must be priced.
Defs []ModelDef
// DefaultRef is the reference name of the model a run that names none
// generates through. Required, and must be one of Defs.
DefaultRef string
// Clients maps a reference name to the client that serves it. The default
// reference needs no entry: it is the Kernel's positional client, which an
// unbound role already resolves to.
Clients map[string]gollem.LLMClient
// DefaultBudget is what a run that names no budget of its own may spend.
// Required and positive: a zero ceiling stops every run (see
// budget.Root.Limiter), so it must be caught here rather than at the first
// transition.
DefaultBudget pricing.NanoUSD
}
ModelPolicyInput is everything a ModelPolicy is built from.
func (ModelPolicyInput) Validate ¶
func (in ModelPolicyInput) Validate() error
Validate enforces the required-field contract so a configuration mistake fails at startup rather than at the first run.
type Scope ¶
type Scope struct {
// WorkspaceID identifies the workspace whose configuration and tools this
// Process runs under. Empty only for the workspace-agnostic draft flow.
WorkspaceID string
// CaseID is the case this Process is pinned to, or 0 when there is none
// (a draft turn, or a create turn before the case exists).
CaseID int64
// ChannelID / ThreadTS locate the run's own Slack thread — the one its Session
// is keyed on, and the one its answer belongs in.
ChannelID string
ThreadTS string
// UIChannelID / UIThreadTS locate the thread the person who triggered the run
// is watching, for the runs where that is a DIFFERENT thread: a case raised by
// a reaction lives in the monitored channel, while the reactor is watching the
// thread they reacted in. Progress, questions and failure notices go here;
// the case's own content still goes to ChannelID / ThreadTS.
//
// Empty means the two are the same thread, which is the case for every other
// run. UITarget resolves that.
UIChannelID string
UIThreadTS string
// ProcessingTS and PreviewTS name the Slack message a case-draft turn's result
// replaces, and are mutually exclusive: ProcessingTS is the "working on it"
// placeholder a fresh mention posted, PreviewTS is the existing draft preview a
// workspace switch updates in place. They live on the scope because the turn
// that posted them returns long before the result exists, and the completion
// handler runs on whichever instance committed the last transition.
ProcessingTS string
PreviewTS string
// SessionID is the model.Session this thread belongs to. It doubles as the
// turn-lock subject id.
SessionID string
// ActorUserID is the Slack user whose access this run acts under. The claim
// middleware turns it into the request-scoped auth token, so a run without
// one acts with no user scope at all.
ActorUserID string
// Lang is the i18n language tag for user-facing copy this run produces.
Lang string
// ToolSets is the list of toolset ids this Process may use, or the single
// element ToolSetsAll.
ToolSets []string
// PrivateCase withholds the workspace-wide knowledge write tools: a private
// case's contents must not reach shared knowledge through an agent write.
PrivateCase bool
// JobID / JobRunID / EventType tie the run to its JobRunLog record. Empty
// for runs that keep no such record.
JobID string
JobRunID string
EventType string
// SlotGated subjects the run to the deployment-wide concurrency gate: a
// claim on it waits for a free execution slot before any transition runs.
//
// The host decides it at spawn rather than the runtime inferring it, so the
// kernel needs no opinion about which kinds of run are rate-limited. Today
// only scheduled Job runs set it — an interactive turn is a person waiting
// for an answer, and a lifecycle or manual run is a single deliberate
// action, so making either queue behind a batch would be the wrong trade.
SlotGated bool
// ProposalID names the case draft this run writes its result into. Empty for
// every run that is not a case-draft turn.
//
// It travels on the run because the Session's ProposalID is MUTABLE: a later
// mention on the same thread points the Session at a new draft, and it can do
// so while this run is still going. A completion handler that read the Session
// instead would write this run's draft into whatever draft the thread points at
// by then.
ProposalID string
// LLMModel is the reference name of the model this run generates through —
// an [[llm_model]] entry's alias, or its model name when it declares none.
// Empty means the deployment's default model. A child Process inherits the
// metadata, so a run's sub-agents generate through the same model.
//
// It carries neither the provider nor the price on purpose: both are read
// from the reference name by the ModelPolicy built at startup. A run whose
// reference name is no longer defined therefore falls back to the default
// client AND the default price together — carrying a rate here would let it
// generate with one model while being metered at another's.
LLMModel string
// Budget is the greatest amount this run may spend. Zero means "not
// specified", and the deployment's default budget applies.
Budget pricing.NanoUSD
}
Scope is the typed view of Process.Metadata: the infrastructure-facing identifiers a claim needs to rebuild an agent's tools, its language, its access actor and its run records.
It is data, not a credential. Every field is derived server-side before Spawn from an already-validated request; nothing here is re-verified at claim time, and nothing here may be treated as proof of anything (agentkit ADR-0011).
func ScopeFrom ¶
ScopeFrom reads a scope back out of Process.Metadata.
A malformed numeric value falls back to the zero value rather than failing: the map was written by Metadata on this same code path, so a bad value means the record was hand-edited or written by an older build, and refusing to run the Process would strand it with no way forward.
func (Scope) Metadata ¶
Metadata renders the scope for Spawn. Empty values are omitted so a reader can tell "not set" from "set to empty", and so the stored map stays small.
type SlotGate ¶
type SlotGate interface {
// Acquire takes a slot for the run identified by ref, and returns
// (nil, nil) when every slot is occupied.
//
// "None free" is an ordinary answer, not an error: the caller waits and is
// asked again. An error means the gate could not tell how many runs are in
// flight, which the caller must treat as "do not proceed".
Acquire(ctx context.Context, ref SlotRef) (SlotHold, error)
}
SlotGate admits a bounded number of runs to execute concurrently across the whole deployment. It is the port behind the Job concurrency limit; the kernel holds no opinion about what a slot means beyond "capacity to run".
The gate is asked once per CLAIM, not once per run. That is what makes it work without any durable hold: a claim is the only scope that brackets a worker's whole stretch of work on a Process, so the token and its heartbeat can live in the claim's memory and context and still be released correctly when the claim ends — including when the instance dies, since an unrenewed slot expires.
type SlotHold ¶
SlotHold is an acquired slot. Release frees it; it must be safe to call more than once.
type SlotRef ¶
SlotRef identifies the run a slot is held for. It exists so the gate can record who holds what without the kernel handing over its Scope.
type ToolDeps ¶
type ToolDeps struct {
Repo interfaces.Repository // required
Registry *model.WorkspaceRegistry // required
SlackBot slacktool.BotService
SlackSearch slacktool.SearchService
SlackRetriever slacktool.MessageRetriever
// SlackPoster backs the channel-pinned poster an unattended run reports
// through. It is a narrower interface than SlackBot on purpose: an LLM holding
// the post tool must not reach the wider Slack surface.
SlackPoster slackpost.Poster
// SlackLimits bounds how much of a Slack read tool's result reaches the
// model context. The zero value leaves both bounds disabled.
SlackLimits slacktool.Limits
NotionClient notiontool.Client
GitHubClient *githubtool.Client
WebFetchClient *webfetch.Client
// JiraTools carries the already-expanded Jira read tools. gollem exposes no
// helper to turn a ToolSet into []Tool, so the CLI expands it once at
// startup and hands the result through as a plain slice.
JiraTools []gollem.Tool
ActionUC core.ActionMutator
ActionStepUC core.ActionStepMutator
CaseUC casewriter.CaseMutator
CaseRefUC core.CaseRefReader
CaseMultiUC casemulti.CaseUsecase
CaseMultiActionUC casemulti.ActionUsecase
MemoUC memotool.MemoMutator
KnowledgeAccessor knowledgetool.KnowledgeAccessor
KnowledgeMutator knowledgetool.KnowledgeMutator
}
ToolDeps carries the clients and usecases every toolset is built from. It is the kernel-side counterpart of the per-host dependency bundles the old runtime assembled per turn: the kernel is built once at startup, and the factory below narrows these down to one Process's scope on every claim.
Optional fields may be nil; the corresponding toolset then resolves to nothing, which is what lets one wiring serve deployments that configure different integrations.
type ToolSetProbe ¶
type ToolSetProbe struct {
// contains filtered or unexported fields
}
ToolSetProbe answers which toolset ids actually resolve to a tool for a given scope. A plan-execute host asks it before Spawn and advertises only what comes back, so its planner is never offered an id that resolves to nothing.
Without it a palette is a fixed list while the tools behind it are conditional on what a deployment configured and on the case the run is pinned to. The planner then assigns a task a toolset the sub-agent does not get — which is how slack__post_to_case_channel came to be requested on a deployment that had built no poster, and the run died on "unknown tool" instead of doing its work.
func NewToolSetProbe ¶
func NewToolSetProbe(d ToolDeps) (*ToolSetProbe, error)
NewToolSetProbe builds the probe from the same ToolDeps the Kernel was built with. Pass the identical value; see resolverFor for why.