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
198 changes: 198 additions & 0 deletions internal/handlers/agent_action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package handlers

// agent_action.go — single source of truth for every `agent_action` string
// returned to the calling LLM agent on a 402/403/409/410/4xx wall.
//
// ─────────────────────────────────────────────────────────────────────────────
// THE U3 CONTRACT
// ─────────────────────────────────────────────────────────────────────────────
//
// Every `agent_action` string returned by this service MUST satisfy these four
// requirements. They are enforced by TestAgentActionContract in
// agent_action_test.go and re-asserted at the handler level by the touch-points
// listed below.
//
// 1. IMPERATIVE OPENING.
// Every string MUST begin with "Tell the user" — the LLM agent's job is
// to re-articulate the sentence to the human in front of it. Starting
// every string with the same imperative makes the contract trivial for
// a downstream LLM to recognize as "verbatim copy I should reproduce."
//
// 2. SPECIFIC REJECTION REASON.
// Every string MUST name the concrete reason the request was rejected:
// the tier ("hobby"), the limit ("5/day"), the policy ("env_policy_denied"),
// the resource ("staging twin"). Generic phrasing ("their plan does not
// allow...") is forbidden — the LLM cannot expand "their plan" into
// something useful without inventing details.
//
// 3. EXACT NEXT ACTION.
// Every string MUST tell the user the precise action that clears the
// wall: "Upgrade to Pro", "Claim the resource", "Provision a twin",
// "Contact support". "Try again later" is not a valid action — that's
// a transient infra failure that should NOT carry an agent_action at all
// (those omit the field; see codeToAgentAction curation principles).
//
// 4. FULL HTTPS URL.
// Every string MUST contain an absolute `https://instanode.dev/...` URL.
// Plain "/pricing" or "the pricing page" forces the LLM to guess the
// hostname. The full URL is reproduced verbatim → guaranteed-correct
// link in the user's terminal.
//
// Plus the soft target:
//
// 5. UNDER 280 CHARS (the "tweet ceiling").
// LLMs reliably reproduce sub-tweet copy verbatim. Longer strings get
// summarized, paraphrased, or truncated. The test asserts < 280 chars
// on every constant below.
//
// ─────────────────────────────────────────────────────────────────────────────
// HOW TO USE
// ─────────────────────────────────────────────────────────────────────────────
//
// 1. Static walls: add a new const to this file, give it a clear name
// (AgentAction<Domain><Reason>), reference it from the handler.
// 2. Dynamic walls (tier/limit interpolation): add a builder function
// (newAgentAction<Domain><Reason>(args...)) here. Builders MUST keep
// the four-contract requirements after interpolation.
// 3. The codeToAgentAction registry in helpers.go is the fallback path for
// handlers that pass only an error code — those defaults are also under
// test.
//
// Adding a new agent_action string OUTSIDE this file (inline in a handler) is
// a contract violation. The audit runs `grep "agent_action" internal/handlers`
// on every PR — new strings must land in this file or extend codeToAgentAction.

import "fmt"

// ─────────────────────────────────────────────────────────────────────────────
// Multi-env / stack tier walls
// ─────────────────────────────────────────────────────────────────────────────

// AgentActionMultiEnvUpgradeRequired is returned when a hobby/free team
// tries to use a multi-env workflow (stack family read, stack promote).
const AgentActionMultiEnvUpgradeRequired = "Tell the user multi-env workflows (staging/production promote, env families) require the Pro plan. Have them upgrade at https://instanode.dev/pricing — takes 30 seconds."

// AgentActionStackPromoteMissingImageRef is returned when the source stack
// predates the image-ref persistence migration (no cached image to copy).
const AgentActionStackPromoteMissingImageRef = "Tell the user this stack predates the image-ref persistence migration, so promote has nothing to redeploy. Redeploy the source stack first at https://instanode.dev/app/stacks, then retry the promote."

// ─────────────────────────────────────────────────────────────────────────────
// Deploy tier walls
// ─────────────────────────────────────────────────────────────────────────────

// newAgentActionDeploymentLimitReached builds the 402 copy returned when a
// team hits its deployments_apps cap (plans.yaml). Names the tier and the
// exact cap, points the user at the upgrade URL.
func newAgentActionDeploymentLimitReached(tier string, limit int) string {
return fmt.Sprintf(
"Tell the user they've hit the %s tier deployment cap (%d apps). Upgrade to Pro for 10 deployments at https://instanode.dev/pricing — takes 30 seconds, no card for upgrade preview.",
tier, limit,
)
}

// ─────────────────────────────────────────────────────────────────────────────
// Storage / vault tier walls (called from respondErrorWithAgentAction)
// ─────────────────────────────────────────────────────────────────────────────

// newAgentActionStorageLimitReached builds the 402 copy returned when a
// team hits the per-tier object-storage cap.
func newAgentActionStorageLimitReached(tier string, limitMB int) string {
return fmt.Sprintf(
"Tell the user they've hit the %s tier storage cap (%dMB). Upgrade to Pro for 5GB at https://instanode.dev/pricing to provision more storage.",
tier, limitMB,
)
}

// newAgentActionVaultQuotaExceeded builds the 402 copy returned when a team
// hits its vault-entry cap for the current plan.
func newAgentActionVaultQuotaExceeded(tier string, maxEntries int) string {
return fmt.Sprintf(
"Tell the user they've hit the %s tier vault cap (%d entries). Upgrade to Pro for more secrets at https://instanode.dev/pricing — takes 30 seconds.",
tier, maxEntries,
)
}

// ─────────────────────────────────────────────────────────────────────────────
// Env-policy / role walls (403)
// ─────────────────────────────────────────────────────────────────────────────

// newAgentActionEnvPolicyDenied builds the 403 copy returned when a team's
// env_policy refuses an action because the caller's role isn't in the
// allowed set. Names the env, the action, the allowed roles, and the
// caller's actual role.
func newAgentActionEnvPolicyDenied(env, action, allowedRoles, callerRole string) string {
if callerRole == "" {
callerRole = "unknown"
}
return fmt.Sprintf(
"Tell the user the %s env requires the %s role to %s. Their role is %s — have a team owner run the prompt at https://instanode.dev/app/team to adjust the policy or run the action.",
env, allowedRoles, action, callerRole,
)
}

// newAgentActionOwnerRequired builds the 403 copy returned when an action
// requires the owner role (e.g. PUT /team/env-policy).
func newAgentActionOwnerRequired(callerRole string) string {
if callerRole == "" {
callerRole = "unknown"
}
return fmt.Sprintf(
"Tell the user updating the team's env-policy requires the owner role. Their role is %s — have the team owner run the prompt from https://instanode.dev/app/team instead.",
callerRole,
)
}

// ─────────────────────────────────────────────────────────────────────────────
// Family-binding walls (resolveResourceBindings → mapBindingError)
// ─────────────────────────────────────────────────────────────────────────────

// newAgentActionBindingInvalidUUID is returned when resource_bindings[KEY]
// is neither a UUID nor a "family:<uuid>" reference.
func newAgentActionBindingInvalidUUID(envKey, rawValue string) string {
return fmt.Sprintf(
"Tell the user the deploy's resource_bindings.%s value must be a resource token UUID or family:<family_root_id>. They provided %q. See https://instanode.dev/docs/family-bindings.",
envKey, rawValue,
)
}

// AgentActionBindingFamilyDisabled is returned when family: prefix is used
// but the server has FAMILY_BINDINGS_ENABLED=false.
const AgentActionBindingFamilyDisabled = "Tell the user this server has family bindings disabled. Remove the family: prefix and pass a raw resource-token UUID instead — see https://instanode.dev/docs/family-bindings."

// newAgentActionBindingNotFound is returned when the referenced resource
// (raw or family root) doesn't exist.
func newAgentActionBindingNotFound(envKey string) string {
return fmt.Sprintf(
"Tell the user the resource referenced in resource_bindings.%s doesn't exist. Have them list their families with GET https://instanode.dev/api/v1/resources/families and use a valid root id.",
envKey,
)
}

// newAgentActionBindingCrossTeam is returned when the referenced resource
// belongs to a different team.
func newAgentActionBindingCrossTeam(envKey string) string {
return fmt.Sprintf(
"Tell the user the resource in resource_bindings.%s belongs to a different team. They can only reference resources owned by their own team — check the team picker at https://instanode.dev/app.",
envKey,
)
}

// newAgentActionBindingNoEnvTwin is returned when a family binding resolves
// to a family that has no member in the deploy's env (e.g. deploying to
// staging but only the production twin exists).
func newAgentActionBindingNoEnvTwin(rootID, resourceName, env string) string {
name := resourceName
if name == "" {
name = rootID
}
return fmt.Sprintf(
"Tell the user to provision a %s twin of %q first: POST https://instanode.dev/api/v1/resources/%s/provision-twin with {\"env\":\"%s\"}. The deploy targets env=%s but no family member exists there.",
env, name, rootID, env, env,
)
}

// AgentActionBindingLookupFailed is returned for transient lookup failures
// during binding resolution (503 path). Even though this is a transient
// error, the user-visible advice is "retry in a few seconds" which is a
// concrete action the LLM can pass on.
const AgentActionBindingLookupFailed = "Tell the user the platform couldn't resolve the resource binding right now. Retry the deploy in ~10 seconds — if it persists, check https://instanode.dev/status."
159 changes: 159 additions & 0 deletions internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package handlers

// agent_action_contract_test.go — enforces the U3 contract (see
// agent_action.go) on every string the handler package returns via
// `agent_action`. One failure here means the contract regressed.
//
// Why one giant table:
// - Reviewers see every wall in one place.
// - Adding a new `agent_action` const without adding a row to this table
// is the violation we want CI to flag (you can grep this file to find
// constants without coverage).

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// agentActionContractCases is the canonical list of every agent_action
// string returned by this package. Static constants are exercised directly;
// builders are exercised with representative inputs.
//
// All strings MUST pass the four contract requirements (plus the < 280 char
// soft ceiling). assertContract enforces them.
func agentActionContractCases() map[string]string {
cases := map[string]string{
// Static constants.
"AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired,
"AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef,
"AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled,
"AgentActionBindingLookupFailed": AgentActionBindingLookupFailed,
"RecycleGateAgentAction": RecycleGateAgentAction,

// Builders — representative inputs covering tier/env/role/limit
// interpolation.
"newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1),
"newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500),
"newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50),
"newAgentActionEnvPolicyDenied(prod,deploy)": newAgentActionEnvPolicyDenied("production", "deploy", "owner", "developer"),
"newAgentActionOwnerRequired(developer)": newAgentActionOwnerRequired("developer"),
"newAgentActionBindingInvalidUUID(KEY)": newAgentActionBindingInvalidUUID("DATABASE_URL", "not-a-uuid"),
"newAgentActionBindingNotFound(KEY)": newAgentActionBindingNotFound("DATABASE_URL"),
"newAgentActionBindingCrossTeam(KEY)": newAgentActionBindingCrossTeam("DATABASE_URL"),
"newAgentActionBindingNoEnvTwin(uuid,name,env)": newAgentActionBindingNoEnvTwin("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "owner-db", "staging"),
}

// codeToAgentAction registry — every entry must also pass the contract.
for code, meta := range codeToAgentAction {
cases["codeToAgentAction["+code+"]"] = meta.AgentAction
}

return cases
}

// assertContract enforces the four U3 requirements + the soft length ceiling
// against a single string. Used by TestAgentActionContract and any future
// per-string assertions.
func assertContract(t *testing.T, name, s string) {
t.Helper()

// 1. Imperative opening.
assert.True(t, strings.HasPrefix(s, "Tell the user"),
"%s: agent_action must start with \"Tell the user\" (the imperative the LLM agent re-articulates to the human). Got: %q", name, s)

// 4. Full HTTPS URL.
assert.Contains(t, s, "https://instanode.dev/",
"%s: agent_action must contain a full https://instanode.dev/ URL — not a relative path. Got: %q", name, s)

// 5. Soft length ceiling — LLMs reproduce sub-tweet copy verbatim.
assert.Less(t, len(s), 280,
"%s: agent_action must be < 280 chars (LLMs paraphrase longer strings). Got %d chars: %q", name, len(s), s)

// Surface area for requirements 2 and 3 (specific reason + exact action).
// These can't be enforced by a generic regex, but the next-action
// vocabulary is bounded: every string MUST contain at least one of the
// known action verbs. This catches passive constructions like
// "Their plan does not allow X" which give the LLM no remedy.
actionVerbs := []string{
"Upgrade", "upgrade",
"Have them", "Have the", "have a", "have them", "have the",
"Wait", "wait", // rate-limit
"Retry", "retry",
"provision", "Provision",
"claim", "Claim", // recycle gate
"log in",
"sign up",
"Ask", "ask", // invitations
"email",
"Remove", "remove", // family-disabled
"Redeploy", "redeploy",
"Confirm", "confirm",
"check ", "Check ", // bindings cross-team / not-found
"use ", "Use ", // bindings not-found
"must be ", // bindings invalid-uuid → action is "must be a UUID"
}
foundVerb := false
for _, v := range actionVerbs {
if strings.Contains(s, v) {
foundVerb = true
break
}
}
assert.True(t, foundVerb,
"%s: agent_action must contain at least one concrete action verb (Upgrade / Have them / Wait / Retry / provision / claim / log in / sign up / Ask / email / Remove / Redeploy / Confirm). Got: %q",
name, s)
}

// TestAgentActionContract is the U3 audit gate. Every string in
// agentActionContractCases must satisfy:
//
// 1. Open with "Tell the user".
// 2. Name a specific reason (covered by per-handler tests).
// 3. Name an exact next action — enforced here via the action-verb
// vocabulary check.
// 4. Contain a full https://instanode.dev/ URL.
// 5. Be < 280 chars.
//
// Adding a new agent_action without adding a row here is a contract
// violation — the audit-trail comment in agent_action.go points reviewers
// at this test.
func TestAgentActionContract(t *testing.T) {
cases := agentActionContractCases()
require.NotEmpty(t, cases, "agentActionContractCases must list every string")

for name, s := range cases {
t.Run(name, func(t *testing.T) {
require.NotEmpty(t, s, "%s: string must not be empty", name)
assertContract(t, name, s)
})
}
}

// TestAgentActionContract_RegistryCoverage guards against the most likely
// regression: someone adds a new code to codeToAgentAction but its string
// silently fails the contract. The map iteration in
// agentActionContractCases covers this — this test just asserts the
// expected codes are present so a deletion is loud.
func TestAgentActionContract_RegistryCoverage(t *testing.T) {
expectedCodes := []string{
// Quota walls.
"quota_exceeded", "storage_limit_reached", "vault_quota_exceeded",
"vault_not_available", "vault_env_not_allowed", "member_limit",
"upgrade_required", "tier_unavailable", "rate_limit_exceeded",
// Auth.
"unauthorized", "auth_required", "invalid_token", "missing_token",
"vault_requires_auth", "invitation_invalid", "already_accepted",
"already_claimed",
// Expired / gone.
"webhook_inactive", "resource_not_found",
// Permission denied.
"forbidden", "last_owner",
}
for _, code := range expectedCodes {
_, ok := codeToAgentAction[code]
assert.True(t, ok, "codeToAgentAction[%q] must be registered — drop is a contract regression", code)
}
}
Loading