Documentation
¶
Overview ¶
Package sysagent is the in-airlock chat agent that lets operators manage agents, bridges, connections, members, A2A, runs, and (later) other tenant resources through tool calls.
No JS VM, no per-agent connections, no Sol — sysagent runs inside airlock and its tools are typed Go functions wrapping existing service.{domain} methods. Every tool call uses the caller's authz.Principal; the service layer's authz.Authorize gates by action.
One sysagent per Airlock instance, per-user multi-conversation chat history. Schema lives in migrations/002_a2a.sql (system_conversations, system_messages, system_audit).
Index ¶
- Constants
- func SystemPrompt(env promptEnv, availableTools tool.Set) string
- type Conversation
- type ConversationDetail
- type Deps
- type ListRunsResult
- type PromptInput
- type RunSummary
- type Service
- func (s *Service) CancelRun(runID uuid.UUID) bool
- func (s *Service) Compact(ctx context.Context, p authz.Principal, conversationID uuid.UUID) (string, error)
- func (s *Service) CreateConversation(ctx context.Context, p authz.Principal, title string) (dbq.SystemConversation, error)
- func (s *Service) DeleteConversation(ctx context.Context, p authz.Principal, conversationID uuid.UUID) error
- func (s *Service) GetConversation(ctx context.Context, p authz.Principal, conversationID uuid.UUID) (ConversationDetail, error)
- func (s *Service) ListConversations(ctx context.Context, p authz.Principal) ([]dbq.SystemConversation, error)
- func (s *Service) ListRuns(ctx context.Context, p authz.Principal, cursor time.Time, limit int32) (ListRunsResult, error)
- func (s *Service) Logger() *zap.Logger
- func (s *Service) NotifyBotCreated(ctx context.Context, conversationID uuid.UUID, botUsername string) error
- func (s *Service) NotifyBuildComplete(ctx context.Context, agentID, conversationID uuid.UUID, status, message string) error
- func (s *Service) NotifyUpgradeComplete(ctx context.Context, agentID, conversationID uuid.UUID, status, message string) error
- func (s *Service) RunPrompt(ctx context.Context, p authz.Principal, conversationID uuid.UUID, ...) (uuid.UUID, error)
- func (s *Service) RunPromptInline(ctx context.Context, p authz.Principal, conversationID uuid.UUID, ...) (uuid.UUID, error)
- func (s *Service) SetBridgeResumer(r bridgeResumer)
Constants ¶
const MaxAuditSummaryBytes = 512
MaxAuditSummaryBytes caps the human-readable result_summary stored in system_audit. Audit rows aren't shown to the LLM and don't need the full 8 KiB tool budget — a one-line gist is enough for forensics.
const MaxToolOutputBytes = 8 * 1024
MaxToolOutputBytes caps every tool result (success and error) at 8 KiB. Tools that hand back larger payloads get truncated with a "[truncated: total=N bytes; refine query or paginate]" suffix, so one runaway list_runs call can't blow through the LLM's context budget. Sized to match the agentsdk JS-binding spill threshold so the LLM sees one consistent "8 KiB-ish" budget across surfaces.
Variables ¶
This section is empty.
Functions ¶
func SystemPrompt ¶
SystemPrompt renders the sysagent chat system prompt: the conventions preamble, the per-turn <env> block, and a tool catalogue generated from the registered (tenant-filtered) tool set so it can't drift from the actual Execute surface. Only the first description line is shown — the full schema reaches the model via the tool envelope.
Types ¶
type Conversation ¶
type Conversation struct {
dbq.SystemConversation
}
Conversation is one row from ListConversations / CreateConversation.
type ConversationDetail ¶
type ConversationDetail struct {
Conversation dbq.SystemConversation
Messages []dbq.SystemMessage
}
ConversationDetail bundles a conversation with its full message history. The caller decides whether to also request paginated tails separately for very long conversations (we don't paginate on the initial Get because chat conversations rarely grow beyond a few dozen messages — agent chat pages from the same shape).
type Deps ¶
type Deps struct {
DB *db.DB
Encryptor secrets.Store
PubSub *realtime.PubSub
PublicURL string // base URL for deep-link tools (no trailing slash)
Logger *zap.Logger
Agents *agentssvc.Service
Bridges *bridgessvc.Service
Catalog *catalogsvc.Service
Conns *connsvc.Service
Execs *execsvc.Service
GitCreds *gitcredssvc.Service
ManagedBots *managedbotssvc.Service
Members *memberssvc.Service
Runs *runssvc.Service
Siblings *siblingssvc.Service
Users *userssvc.Service
}
Deps bundles the dependencies New requires. Pulled out as a struct so the call site stays readable as the service set grows.
type ListRunsResult ¶
type ListRunsResult struct {
Runs []RunSummary
NextCursor time.Time
}
ListRunsResult bundles a page of runs with the cursor for the next page (zero StartedAt when no further pages exist).
type PromptInput ¶
type PromptInput struct {
Message string
// Platform is the channel for the <env> block ("web"/"telegram"), set
// explicitly by the caller — never inferred. Empty omits the line.
Platform string
Approved *bool
// ResumeRunID on an approve/deny names the run whose
// confirmation is being resolved. RunPrompt waits for that run to suspend
// before dispatching the resume, so an approval that beats the async
// suspend write isn't rejected as a state mismatch. It is required for a
// confirmation response.
ResumeRunID string
}
PromptInput is the per-call payload the HTTP handler hands to RunPrompt. Exactly one of Message / Approved should be set per the /system/conversations/{id}/prompt API:
- Message non-empty + Approved nil = fresh operator turn.
- Approved non-nil + Message empty = resolution of a pending confirmation (true=execute, false=synthesize denial).
- Both empty = auto-resume after an injected system event (see Service.resumeConversation).
type RunSummary ¶
type RunSummary struct {
ID uuid.UUID
ConversationID uuid.UUID
ConversationTitle string
Status string // 'running' | 'suspended' | 'complete' | 'error' | 'cancelled'
TriggerType string // 'prompt' | 'bridge' | 'event'
MessagePreview string // first linked message for the turn (” when no message is bound)
ErrorMessage string
CostEstimate float64
StartedAt time.Time
FinishedAt *time.Time
}
RunSummary is one row from the caller's sysagent activity view — run lifecycle (status + timestamps) plus the parent conversation's title so the UI can render without a per-row fetch.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the central sysagent hub. Owns the dependency set every tool body needs (db, encryptor, all the per-domain service handles, logger) plus the public URL used to mint deep-link tool results.
One instance per Airlock; constructed in api/router.go and shared across HTTP requests. Per-request state (Principal, conversation, message history) lives on the chat loop, not here.
func New ¶
New wires the sysagent Service. Fail-loud on nil deps — every field is required (AGENTS.md rule).
func (*Service) CancelRun ¶
CancelRun aborts an in-flight chat goroutine. Returns true if a matching run was found and cancelled. The runChat goroutine sees ctx.Done() through sol.Runner and exits with status='cancelled' via the existing sol.RunCancelled branch.
func (*Service) Compact ¶
func (s *Service) Compact(ctx context.Context, p authz.Principal, conversationID uuid.UUID) (string, error)
Compact runs sol's user-triggered compaction on a sysagent conversation in-process. Loads the conversation's post-checkpoint messages, asks the LLM to summarize, persists the summary as a new system_messages row, and advances context_checkpoint_message_id.
Unlike RunPrompt this is synchronous from the caller's perspective: returns once compaction completes with the summary text the user will see in chat. Mirrors the agent path's /compact (which forwards to the agent container and lets Sol.Runner.Compact emit the summary as a normal assistant message), just executed locally because sysagent has no agent container.
func (*Service) CreateConversation ¶
func (s *Service) CreateConversation(ctx context.Context, p authz.Principal, title string) (dbq.SystemConversation, error)
CreateConversation inserts a fresh conversation for the caller. title is optional; the DB default is "New chat".
func (*Service) DeleteConversation ¶
func (s *Service) DeleteConversation(ctx context.Context, p authz.Principal, conversationID uuid.UUID) error
DeleteConversation removes a conversation (and via ON DELETE CASCADE its messages, runs, and any pending checkpoint state). audit rows for the conversation stay (ON DELETE SET NULL preserves forensics — what happened in a now-deleted conversation is still recoverable).
func (*Service) GetConversation ¶
func (s *Service) GetConversation(ctx context.Context, p authz.Principal, conversationID uuid.UUID) (ConversationDetail, error)
GetConversation returns the conversation + its full message history (including pre-checkpoint rows + checkpoint markers — the UI's full timeline, not the LLM context filter). 404 for a missing conversation, 404 (not 403) for someone else's — exposing existence to non-owners would leak metadata.
func (*Service) ListConversations ¶
func (s *Service) ListConversations(ctx context.Context, p authz.Principal) ([]dbq.SystemConversation, error)
ListConversations returns the caller's own conversations, newest-active first. Ownership is enforced at the query level — conversations aren't shared.
func (*Service) ListRuns ¶
func (s *Service) ListRuns(ctx context.Context, p authz.Principal, cursor time.Time, limit int32) (ListRunsResult, error)
ListRuns returns the caller's recent sysagent runs across all their conversations, newest first. Cursor is the started_at of the last row from the previous page; zero fetches the newest page. Caller must be authenticated; the query is owner-scoped (user_id = p.UserID) so no cross-user leak is possible.
Gating is uniform with the other sysagent reads: any authenticated user can list their OWN runs, which is what the policy table already expresses via TenantUserView for the user directory. We don't add a new Action here because the data is owner-scoped at the query level — the WHERE clause IS the gate.
func (*Service) Logger ¶
Logger exposes the package logger for callers that want to surface sysagent events through the same channel (e.g. the API handler logging request failures).
func (*Service) NotifyBotCreated ¶
func (s *Service) NotifyBotCreated(ctx context.Context, conversationID uuid.UUID, botUsername string) error
NotifyBotCreated announces a freshly-created managed Telegram bot into the system-agent conversation that requested it (create_tg_bot) and auto-resumes, so the agent hands the operator the open link in-character. Same inject-then- resume mechanism as NotifyBuildComplete; agentID is nil (the event is about a bot, not an agent build — the WS envelope's AgentId is unused on bridges).
func (*Service) NotifyBuildComplete ¶
func (s *Service) NotifyBuildComplete(ctx context.Context, agentID, conversationID uuid.UUID, status, message string) error
NotifyBuildComplete satisfies builder.PostBuildSystemNotifier — the initial-build counterpart of NotifyUpgradeComplete. Triggered after a build kicked off from the system-agent create_agent tool finishes; same inject-then-resume mechanism, build-flavored prefix.
func (*Service) NotifyUpgradeComplete ¶
func (s *Service) NotifyUpgradeComplete(ctx context.Context, agentID, conversationID uuid.UUID, status, message string) error
NotifyUpgradeComplete satisfies builder.PostUpgradeSystemNotifier for the sysagent surface. Triggered by the builder after a build initiated from a sysagent-conversation upgrade tool finishes (success, failure, or out-of-scope refusal). Mirrors agent chat's conversationsHandler.NotifyUpgradeComplete (api/conversations.go) so the operator sees the outcome through the same render path the LLM-driving "user message" pattern uses.
What lands in the conversation: a single user-role system_messages row prefixed with [Upgrade succeeded] / [Upgrade failed] / [Request declined] — the prefix is what tells the LLM this is a system-injected event, not a real operator statement. Following agentsdk's deliberate choice not to write a separate assistant bubble: keeping the LLM's read of the situation unambiguous matters more than dual rendering.
agentID is the agent that was being upgraded; the message body references it so the LLM has context when responding. conversationID is the sysagent conversation that triggered the upgrade.
func (*Service) RunPrompt ¶
func (s *Service) RunPrompt(ctx context.Context, p authz.Principal, conversationID uuid.UUID, input PromptInput) (uuid.UUID, error)
RunPrompt creates the run row, hands off the chat to a background goroutine, and returns the new run id immediately. The HTTP handler returns {runId, conversationId} to the caller and the frontend subscribes to the conversation topic via WS to receive the streamed events (same pattern agent chat uses).
The synchronous part is intentionally small (create row, return id). Everything else — model resolve, message build, sol.Runner.Run, suspension handling, persistence — runs in the goroutine so a slow LLM doesn't tie up the HTTP request.
func (*Service) RunPromptInline ¶
func (s *Service) RunPromptInline(ctx context.Context, p authz.Principal, conversationID uuid.UUID, text, platform string, approved *bool, resumeRunID string, extraSink eventstream.Sink, onStart func(runID uuid.UUID)) (uuid.UUID, error)
RunPromptInline is RunPrompt's synchronous sibling: the chat loop runs on the caller's goroutine, and an additional eventstream.Sink is fanned every bus event alongside the WS pubsubSink. Used by the bridge path so a system-bridge poller can block on a turn (its poller is single-threaded per bridge → natural per-thread serialization) and translate sysagent events into bridge ResponseEvents.
Takes a full PromptInput so the bridge path can resolve pending confirmations (Approve/Reject button taps) the same way the web UI does — set Approved + ResumeRunID. A plain user message leaves both nil/empty. When extraSink is non-nil the WS pubsub is bypassed entirely; only the bridge sees events. Returns once the chat loop exits — RunCompleted / RunFailed / RunSuspended / RunCancelled all reach this.
func (*Service) SetBridgeResumer ¶
func (s *Service) SetBridgeResumer(r bridgeResumer)
SetBridgeResumer wires the bridge resume path. Called once at startup; nil leaves bridge follow-up delivery disabled (web notifications still work).
Source Files
¶
- audit.go
- busbridge.go
- chat.go
- control.go
- conversations.go
- executor.go
- handler.go
- output.go
- principal.go
- prompt.go
- runs.go
- service.go
- sessionstore.go
- tools.go
- tools_agent_mutate.go
- tools_agent_read.go
- tools_bridges.go
- tools_connections.go
- tools_deeplinks.go
- tools_envexec.go
- tools_runs.go
- tools_self.go
- tools_siblings_members.go
- usage.go