Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions go/gen/compass/v1/compass.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 12 additions & 5 deletions go/internal/runner/agent_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ var agentCommand = []string{"compass-agent"}
// (packages/compass-agent/src/cli.ts): HOME locates the provider seed,
// COMPASS_WORKDIR is the session cwd, COMPASS_MODEL selects the model,
// COMPASS_PERSONA is the identity overlay appended to the system prompt,
// COMPASS_RESUME_SESSION_FILE is the absolute in-container path of a
// server-reconstructed session file the agent loads to resume. Empty Model,
// Persona, or ResumeSessionFile is omitted rather than exported blank, so the
// agent falls back to its SDK default (or a fresh session) instead of receiving
// a value it must special-case.
// COMPASS_ROLE is the operator-set block-0 selector delivered as the
// container's customSystemPrompt, COMPASS_RESUME_SESSION_FILE is the absolute
// in-container path of a server-reconstructed session file the agent loads to
// resume. Empty Model, Persona, Role, or ResumeSessionFile is omitted rather
// than exported blank, so the agent falls back to its SDK default (or a fresh
// session) instead of receiving a value it must special-case.
type AgentEnv struct {
// UID is the agent user the exec runs as. Set explicitly because podman
// strips the container's ambient capabilities only when --user is passed:
Expand All @@ -58,6 +59,9 @@ type AgentEnv struct {
Model string
// Persona is the server-authoritative identity overlay, or empty for none.
Persona string
// Role is the server-authoritative operator-set block-0 selector, delivered
// as the container's customSystemPrompt, or empty for none.
Role string
// ResumeSessionFile is the absolute in-container path of the materialized
// resume session file, or empty for a fresh start.
ResumeSessionFile string
Expand All @@ -82,6 +86,9 @@ func (e AgentEnv) execSpec() runtime.StreamingExecSpec {
if e.Persona != "" {
spec.Env["COMPASS_PERSONA"] = e.Persona
}
if e.Role != "" {
spec.Env["COMPASS_ROLE"] = e.Role
}
if e.ResumeSessionFile != "" {
spec.Env["COMPASS_RESUME_SESSION_FILE"] = e.ResumeSessionFile
}
Expand Down
31 changes: 31 additions & 0 deletions go/internal/runner/agentenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,37 @@ func TestExecSpecExportsPersonaOnlyWhenConfigured(t *testing.T) {
}
}

// COMPASS_ROLE is exported only when a role is configured. An empty Role must
// leave the key ABSENT, not mapped to "": the agent treats an absent var as "no
// role" and stays on its default block-0 prompt, so exporting a blank value
// would force it to special-case an empty string. A non-empty Role is exported
// verbatim — this is the assertion that proves the block-0 selector is wired
// from the AgentEnv seam at all.
func TestExecSpecExportsRoleOnlyWhenConfigured(t *testing.T) {
tests := []struct {
name string
role string
want string
present bool
}{
{name: "empty role omits the key entirely", role: "", present: false},
{name: "configured role is exported verbatim", role: "manager", want: "manager", present: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
spec := AgentEnv{UID: 1000, HomeDir: "/home/coder", Workdir: "/srv/checkout", Role: tc.role}.execSpec()

got, ok := spec.Env["COMPASS_ROLE"]
if ok != tc.present {
t.Fatalf("COMPASS_ROLE present = %v (value %q), want present = %v", ok, got, tc.present)
}
if ok && got != tc.want {
t.Fatalf("COMPASS_ROLE = %q, want %q", got, tc.want)
}
})
}
}

// SECURITY-LOAD-BEARING. The container is created with --cap-add NET_ADMIN
// (runtime/agent.go:212) so its root entrypoint can arm the nft egress
// firewall. Podman strips a container's ambient capabilities from an exec ONLY
Expand Down
1 change: 1 addition & 0 deletions go/internal/runner/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ func (h *agentHost) agentEnv(handle *runtime.AgentHandle) AgentEnv {
Workdir: handle.CheckoutDir(),
Model: h.model,
Persona: handle.Persona(),
Role: handle.Role(),
}
}

Expand Down
1 change: 1 addition & 0 deletions go/internal/runner/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func (b *configSpecBuilder) BuildSpec(req *compassv1.ProvisionAgentWorkspaceRequ
Egress: d.Egress,
Mounts: d.Mounts,
Persona: req.GetPersona(),
Role: req.GetRole(),
}, nil
}

Expand Down
20 changes: 20 additions & 0 deletions go/internal/runner/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ func TestBuildSpecMapsPersona(t *testing.T) {
}
}

// BuildSpec maps the request's server-authoritative role onto the AgentSpec so
// it rides through to the agent's block-0 customSystemPrompt. A bug that dropped
// it would boot every agent with no role and the default block-0 prompt.
func TestBuildSpecMapsRole(t *testing.T) {
builder, err := NewConfigSpecBuilder(goodDefaults())
if err != nil {
t.Fatalf("NewConfigSpecBuilder: %v", err)
}
spec, err := builder.BuildSpec(&compassv1.ProvisionAgentWorkspaceRequest{
AgentAccountId: strings.Repeat("a", 32),
Role: "manager",
})
if err != nil {
t.Fatalf("BuildSpec = %v", err)
}
if spec.Role != "manager" {
t.Fatalf("spec.Role = %q, want %q (req.Role must reach the AgentSpec)", spec.Role, "manager")
}
}

// The container name is prefix+agent_account_id. A bug in the name derivation
// would collide containers or misroute the per-agent workspace.
func TestBuildSpecDerivesName(t *testing.T) {
Expand Down
8 changes: 8 additions & 0 deletions go/internal/runtime/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ type AgentSpec struct {
// Persona is the server-authoritative identity overlay for this agent,
// appended to the agent's system prompt at boot. Empty means no overlay.
Persona string
// Role is the server-authoritative operator-set block-0 selector for this
// agent, delivered as the container's customSystemPrompt at boot. Empty
// means no role (default OMP block-0).
Role string
}

// AgentHandle is a live agent container: the resolved id plus the spec it was
Expand Down Expand Up @@ -76,6 +80,10 @@ func (h *AgentHandle) HomeDir() string { return h.spec.Workspace.HomeDir }
// empty for none.
func (h *AgentHandle) Persona() string { return h.spec.Persona }

// Role returns the server-authoritative operator-set block-0 selector for this
// agent, or empty for none.
func (h *AgentHandle) Role() string { return h.spec.Role }

// StageError wraps a container runtime error with the lifecycle stage it
// failed at, so a failure is diagnosable without a container inspect.
type StageError struct {
Expand Down
21 changes: 13 additions & 8 deletions go/internal/store/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func (s *Store) adminByHandle(ctx context.Context, handle string) (Account, erro
const q = `
SELECT a.id, a.handle, a.display_name,
u.role,
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.parent_agent_id
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.role, ag.parent_agent_id
FROM accounts a
LEFT JOIN user_accounts u ON u.account_id = a.id
LEFT JOIN agent_accounts ag ON ag.account_id = a.id
Expand Down Expand Up @@ -154,8 +154,8 @@ func (s *Store) CreateAgent(ctx context.Context, ownerUserID AccountID, a NewAge
return Account{}, fmt.Errorf("store: insert account: %w", err)
}
if _, err := tx.Exec(ctx,
"INSERT INTO agent_accounts (account_id, owner_user_id, home_channel_id, persona, parent_agent_id) VALUES ($1, $2, $3, $4, NULLIF($5, ''))",
accountID, string(ownerUserID), channelID, a.Persona, string(a.ParentAgentID),
"INSERT INTO agent_accounts (account_id, owner_user_id, home_channel_id, persona, role, parent_agent_id) VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''))",
accountID, string(ownerUserID), channelID, a.Persona, a.Role, string(a.ParentAgentID),
); err != nil {
// Both FKs on agent_accounts land here: parent_agent_id (a supplied
// parent that does not resolve to an agent) and owner_user_id (an
Expand Down Expand Up @@ -212,6 +212,7 @@ func (s *Store) CreateAgent(ctx context.Context, ownerUserID AccountID, a NewAge
OwnerUserID: ownerUserID,
HomeChannelID: ChannelID(channelID),
Persona: a.Persona,
Role: a.Role,
ParentAgentID: a.ParentAgentID,
},
}, nil
Expand All @@ -225,7 +226,7 @@ func (s *Store) GetAccount(ctx context.Context, id AccountID) (Account, error) {
const q = `
SELECT a.id, a.handle, a.display_name,
u.role,
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.parent_agent_id
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.role, ag.parent_agent_id
FROM accounts a
LEFT JOIN user_accounts u ON u.account_id = a.id
LEFT JOIN agent_accounts ag ON ag.account_id = a.id
Expand Down Expand Up @@ -398,7 +399,7 @@ func (s *Store) ReparentAgent(ctx context.Context, caller, agentAccountID, newPa
const q = `
SELECT a.id, a.handle, a.display_name,
u.role,
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.parent_agent_id
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.role, ag.parent_agent_id
FROM accounts a
LEFT JOIN user_accounts u ON u.account_id = a.id
LEFT JOIN agent_accounts ag ON ag.account_id = a.id
Expand Down Expand Up @@ -484,7 +485,7 @@ func (s *Store) AgentByHandle(ctx context.Context, handle string) (Account, erro
const q = `
SELECT a.id, a.handle, a.display_name,
u.role,
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.parent_agent_id
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.role, ag.parent_agent_id
FROM accounts a
LEFT JOIN user_accounts u ON u.account_id = a.id
LEFT JOIN agent_accounts ag ON ag.account_id = a.id
Expand Down Expand Up @@ -542,7 +543,7 @@ func (s *Store) ListAccounts(ctx context.Context, visibleTo AccountID) ([]Accoun
const q = `
SELECT a.id, a.handle, a.display_name,
u.role,
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.parent_agent_id` +
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.role, ag.parent_agent_id` +
accountVisibleFromWhere + `
ORDER BY a.handle`
rows, err := s.pool.Query(ctx, q, string(visibleTo))
Expand Down Expand Up @@ -594,9 +595,10 @@ func scanAccount(row pgx.Row) (Account, error) {
ownerUserID *string
homeChannelID *string
persona *string
agRole *string
parentAgentID *string
)
if err := row.Scan(&id, &handle, &displayName, &role, &ownerUserID, &homeChannelID, &persona, &parentAgentID); err != nil {
if err := row.Scan(&id, &handle, &displayName, &role, &ownerUserID, &homeChannelID, &persona, &agRole, &parentAgentID); err != nil {
return Account{}, err
}
acc.ID = AccountID(id)
Expand All @@ -613,6 +615,9 @@ func scanAccount(row pgx.Row) (Account, error) {
if persona != nil {
agent.Persona = *persona
}
if agRole != nil {
agent.Role = *agRole
}
if parentAgentID != nil {
agent.ParentAgentID = AccountID(*parentAgentID)
}
Expand Down
66 changes: 66 additions & 0 deletions go/internal/store/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,72 @@ func TestCreateAgentPersonaDefaultsEmpty(t *testing.T) {
}
}

func TestCreateAgentRoleRoundTrips(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
owner := mustUser(t, s, "owner")

const role = "manager"
created, err := s.CreateAgent(ctx, owner.ID,
NewAgent{Handle: "agent", DisplayName: "Agent", Role: role})
if err != nil {
t.Fatalf("CreateAgent: %v", err)
}
if created.Agent == nil || created.Agent.Role != role {
t.Fatalf("CreateAgent returned role = %q, want %q", created.Agent.Role, role)
}

got, err := s.GetAccount(ctx, created.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if !got.IsAgent() || got.Agent.Role != role {
t.Fatalf("GetAccount role = %q, want %q", got.Agent.Role, role)
}

// The third scanAccount-feeding SELECT: the role must also round-trip
// through the owner-scoped ListAccounts projection, not just the id reads.
listed, err := s.ListAccounts(ctx, owner.ID)
if err != nil {
t.Fatalf("ListAccounts(owner): %v", err)
}
var found *Account
for i := range listed {
if listed[i].ID == created.ID {
found = &listed[i]
break
}
}
if found == nil {
t.Fatalf("ListAccounts(owner) did not return created agent %s", created.ID)
}
if !found.IsAgent() || found.Agent.Role != role {
t.Fatalf("ListAccounts role = %q, want %q", found.Agent.Role, role)
}
}

func TestCreateAgentRoleDefaultsEmpty(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
owner := mustUser(t, s, "owner")

created, err := s.CreateAgent(ctx, owner.ID, NewAgent{Handle: "agent", DisplayName: "Agent"})
if err != nil {
t.Fatalf("CreateAgent: %v", err)
}
if created.Agent == nil || created.Agent.Role != "" {
t.Fatalf("CreateAgent default role = %q, want empty", created.Agent.Role)
}

got, err := s.GetAccount(ctx, created.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if !got.IsAgent() || got.Agent.Role != "" {
t.Fatalf("GetAccount default role = %q, want empty", got.Agent.Role)
}
}

func TestBootstrapAdminCreatesAdmin(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
Expand Down
4 changes: 2 additions & 2 deletions go/internal/store/agent_tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import (
)

// agentTreeProjection is the account column list + FROM/JOINs shared by the
// three tree reads. It mirrors the ListAccounts projection (accounts.go:498-501)
// three tree reads. It mirrors the ListAccounts projection (accounts.go:544-546)
// exactly so a tree-read row scans through the same scanAccount helper and reads
// identically to every other account read. The join to agent_accounts is INNER:
// the tree is agents-only, so a user account can never appear in a tree read.
// The trailing WHERE is completed by each caller; $1 is the addressing id.
const agentTreeProjection = `
SELECT a.id, a.handle, a.display_name,
u.role,
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.parent_agent_id
ag.owner_user_id, ag.home_channel_id, ag.persona, ag.role, ag.parent_agent_id
FROM accounts a
LEFT JOIN user_accounts u ON u.account_id = a.id
JOIN agent_accounts ag ON ag.account_id = a.id`
Expand Down
6 changes: 6 additions & 0 deletions go/internal/store/inputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ type NewAgent struct {
// Empty means no persona override; the caller supplies it and the server
// stores it verbatim, never synthesizing one.
Persona string
// Role is the agent's operator-set block-0 selector (SEA-1732 T10). Empty
// means no role (default OMP block-0); the caller supplies it and the server
// stores it verbatim, never synthesizing one. Unlike Persona (an append
// overlay), the label selects config/prompts/<role>/SYSTEM.md, delivered as
// the container's customSystemPrompt.
Role string
// ParentAgentID is the agent's parent in the agent tree; empty = root. The
// server validates it (same resolved owner, must exist) before insert; an
// empty value inserts SQL NULL, satisfying the FK.
Expand Down
15 changes: 15 additions & 0 deletions go/internal/store/migrations/0015_agent_role.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- 0015_agent_role: the agent's operator-set role selector (SEA-1732 T10).
-- role is the operator-set selector for the agent's block-0 system prompt, its
-- source-of-truth field on the agent account (Matt-ruled 2026-08-07: source =
-- AgentAccount, mirroring persona). Where persona is an APPEND overlay layered
-- after the default prompt, role REPLACES block-0 via customSystemPrompt: the
-- label selects config/prompts/<role>/SYSTEM.md from the mounted config tree.
-- Empty string means no role — the agent keeps OMP's default block-0. It is
-- read at provision time and materialized to the container by the runner (a
-- separate lane); this migration is only the durable column + the store
-- round-trip that carries it.
--
-- NOT NULL DEFAULT '' so every existing and future agent row always has a value:
-- a joined agent row reads the empty default rather than NULL, matching the
-- "empty = no role" contract and keeping the create/read path branch-free.
ALTER TABLE agent_accounts ADD COLUMN role TEXT NOT NULL DEFAULT '';
Loading
Loading